mandrel 2.16.0 → 2.18.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/docs/agentrc-reference.json +10 -0
- package/.agents/docs/configuration.md +9 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +48 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +67 -8
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/config/temp-paths.js +27 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -39,8 +39,44 @@ const progress = Logger.createProgress('drain-pending-cleanup', {
|
|
|
39
39
|
stderr: false,
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The drain core, extracted from the CLI shell so the whole decision table —
|
|
44
|
+
* empty manifest, dry-run report, drain + escalate reporting — is reachable
|
|
45
|
+
* without a real worktree ledger or a real process kill.
|
|
46
|
+
*
|
|
47
|
+
* Every seam on the optional final `deps` parameter defaults to the real
|
|
48
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
|
|
49
|
+
* every production caller are unchanged.
|
|
50
|
+
*
|
|
51
|
+
* @param {string[]} [argv]
|
|
52
|
+
* @param {{
|
|
53
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
54
|
+
* readManifestImpl?: typeof readManifest,
|
|
55
|
+
* findHoldersInPathImpl?: typeof findHoldersInPath,
|
|
56
|
+
* forceDrainImpl?: typeof forceDrainPendingCleanup,
|
|
57
|
+
* gitImpl?: typeof gitUtils,
|
|
58
|
+
* projectRoot?: string,
|
|
59
|
+
* progressImpl?: (phase: string, message: string) => void,
|
|
60
|
+
* logger?: { error: Function },
|
|
61
|
+
* }} [deps]
|
|
62
|
+
* @returns {Promise<{ drained: number[], remaining: number } | { remaining: number }>}
|
|
63
|
+
*/
|
|
64
|
+
export async function runDrainPendingCleanup(
|
|
65
|
+
argv = process.argv.slice(2),
|
|
66
|
+
deps = {},
|
|
67
|
+
) {
|
|
68
|
+
const {
|
|
69
|
+
resolveConfigImpl = resolveConfig,
|
|
70
|
+
readManifestImpl = readManifest,
|
|
71
|
+
findHoldersInPathImpl = findHoldersInPath,
|
|
72
|
+
forceDrainImpl = forceDrainPendingCleanup,
|
|
73
|
+
gitImpl = gitUtils,
|
|
74
|
+
projectRoot = PROJECT_ROOT,
|
|
75
|
+
progressImpl = progress,
|
|
76
|
+
logger = Logger,
|
|
77
|
+
} = deps;
|
|
43
78
|
const { values } = parseArgs({
|
|
79
|
+
args: argv,
|
|
44
80
|
options: {
|
|
45
81
|
escalate: { type: 'boolean', default: true },
|
|
46
82
|
'dry-run': { type: 'boolean', default: false },
|
|
@@ -49,20 +85,23 @@ async function main() {
|
|
|
49
85
|
strict: false,
|
|
50
86
|
});
|
|
51
87
|
|
|
52
|
-
const config =
|
|
88
|
+
const config = resolveConfigImpl();
|
|
53
89
|
const wtConfig = config.delivery?.worktreeIsolation;
|
|
54
90
|
const worktreeRoot = path.resolve(
|
|
55
|
-
|
|
91
|
+
projectRoot,
|
|
56
92
|
values['worktree-root'] ?? wtConfig?.root ?? '.worktrees',
|
|
57
93
|
);
|
|
58
94
|
|
|
59
|
-
const before =
|
|
95
|
+
const before = readManifestImpl(worktreeRoot);
|
|
60
96
|
if (before.length === 0) {
|
|
61
|
-
|
|
62
|
-
|
|
97
|
+
progressImpl(
|
|
98
|
+
'SCAN',
|
|
99
|
+
'pending-cleanup manifest is empty — nothing to drain.',
|
|
100
|
+
);
|
|
101
|
+
return { remaining: 0 };
|
|
63
102
|
}
|
|
64
103
|
|
|
65
|
-
|
|
104
|
+
progressImpl(
|
|
66
105
|
'SCAN',
|
|
67
106
|
`pending-cleanup manifest has ${before.length} entry(ies): ${before
|
|
68
107
|
.map((e) => `story-${e.storyId}(attempts=${e.attempts ?? 0})`)
|
|
@@ -71,8 +110,8 @@ async function main() {
|
|
|
71
110
|
|
|
72
111
|
if (values['dry-run']) {
|
|
73
112
|
for (const entry of before) {
|
|
74
|
-
const holders =
|
|
75
|
-
|
|
113
|
+
const holders = findHoldersInPathImpl(entry.path);
|
|
114
|
+
progressImpl(
|
|
76
115
|
'DRY-RUN',
|
|
77
116
|
`story-${entry.storyId} path=${entry.path} holders=${holders.length}` +
|
|
78
117
|
(holders.length > 0
|
|
@@ -80,23 +119,23 @@ async function main() {
|
|
|
80
119
|
: ''),
|
|
81
120
|
);
|
|
82
121
|
}
|
|
83
|
-
return;
|
|
122
|
+
return { remaining: before.length };
|
|
84
123
|
}
|
|
85
124
|
|
|
86
|
-
const result = await
|
|
87
|
-
repoRoot:
|
|
125
|
+
const result = await forceDrainImpl({
|
|
126
|
+
repoRoot: projectRoot,
|
|
88
127
|
worktreeRoot,
|
|
89
|
-
git:
|
|
128
|
+
git: gitImpl,
|
|
90
129
|
escalate: values.escalate,
|
|
91
130
|
logger: {
|
|
92
|
-
info: (m) =>
|
|
93
|
-
warn: (m) =>
|
|
94
|
-
error: (m) =>
|
|
131
|
+
info: (m) => progressImpl('DRAIN', m),
|
|
132
|
+
warn: (m) => progressImpl('DRAIN', `⚠️ ${m}`),
|
|
133
|
+
error: (m) => logger.error(`[drain-pending-cleanup] ${m}`),
|
|
95
134
|
},
|
|
96
135
|
});
|
|
97
136
|
|
|
98
137
|
if (result.drained.length > 0) {
|
|
99
|
-
|
|
138
|
+
progressImpl(
|
|
100
139
|
'DRAIN',
|
|
101
140
|
`✅ drained ${result.drained.length} entry(ies): ${result.drained
|
|
102
141
|
.map((id) => `story-${id}`)
|
|
@@ -107,10 +146,10 @@ async function main() {
|
|
|
107
146
|
const summary = result.escalated
|
|
108
147
|
.map((id) => `story-${id}=[${(result.killedPids[id] ?? []).join(',')}]`)
|
|
109
148
|
.join(', ');
|
|
110
|
-
|
|
149
|
+
progressImpl('ESCALATE', `terminated holders: ${summary}`);
|
|
111
150
|
}
|
|
112
151
|
if (result.noHolders && result.noHolders.length > 0) {
|
|
113
|
-
|
|
152
|
+
progressImpl(
|
|
114
153
|
'ESCALATE',
|
|
115
154
|
`⚠️ no user-mode holders for: ${result.noHolders
|
|
116
155
|
.map((id) => `story-${id}`)
|
|
@@ -120,7 +159,7 @@ async function main() {
|
|
|
120
159
|
);
|
|
121
160
|
}
|
|
122
161
|
if (result.persistent.length > 0) {
|
|
123
|
-
|
|
162
|
+
progressImpl(
|
|
124
163
|
'PERSIST',
|
|
125
164
|
`⚠️ persistent-lock remains on: ${result.persistent
|
|
126
165
|
.map((id) => `story-${id}`)
|
|
@@ -128,7 +167,7 @@ async function main() {
|
|
|
128
167
|
);
|
|
129
168
|
}
|
|
130
169
|
if (result.stillPending.length > 0) {
|
|
131
|
-
|
|
170
|
+
progressImpl(
|
|
132
171
|
'STILL-PENDING',
|
|
133
172
|
`⚠️ still-pending (below threshold): ${result.stillPending
|
|
134
173
|
.map((id) => `story-${id}`)
|
|
@@ -136,12 +175,17 @@ async function main() {
|
|
|
136
175
|
);
|
|
137
176
|
}
|
|
138
177
|
|
|
139
|
-
const after =
|
|
140
|
-
|
|
178
|
+
const after = readManifestImpl(worktreeRoot);
|
|
179
|
+
progressImpl(
|
|
141
180
|
'DONE',
|
|
142
181
|
`pending-cleanup manifest now has ${after.length} entry(ies). ` +
|
|
143
182
|
`Drained=${result.drained.length}, escalated=${result.escalated.length}, persistent=${result.persistent.length}.`,
|
|
144
183
|
);
|
|
184
|
+
return { drained: result.drained, remaining: after.length };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function main() {
|
|
188
|
+
await runDrainPendingCleanup();
|
|
145
189
|
}
|
|
146
190
|
|
|
147
191
|
runAsCli(import.meta.url, main, {
|
|
@@ -83,16 +83,21 @@ export function planChecklists(lenses, workflowExists, readWorkflow) {
|
|
|
83
83
|
* content; `missing` lists lenses with no `audit-<lens>.md`; `strays` lists
|
|
84
84
|
* on-disk checklist basenames that map to no current lens.
|
|
85
85
|
*/
|
|
86
|
-
export function buildExpected(
|
|
87
|
-
|
|
86
|
+
export function buildExpected({
|
|
87
|
+
fsImpl = fs,
|
|
88
|
+
lenses = AUDIT_LENSES,
|
|
89
|
+
workflowsDir = WORKFLOWS_DIR,
|
|
90
|
+
checklistsDir = CHECKLISTS_DIR,
|
|
91
|
+
} = {}) {
|
|
92
|
+
const workflowPath = (lens) => path.join(workflowsDir, `audit-${lens}.md`);
|
|
88
93
|
const { expected, missing } = planChecklists(
|
|
89
|
-
|
|
90
|
-
(lens) =>
|
|
91
|
-
(lens) =>
|
|
94
|
+
lenses,
|
|
95
|
+
(lens) => fsImpl.existsSync(workflowPath(lens)),
|
|
96
|
+
(lens) => fsImpl.readFileSync(workflowPath(lens), 'utf8'),
|
|
92
97
|
);
|
|
93
98
|
|
|
94
|
-
const onDisk =
|
|
95
|
-
?
|
|
99
|
+
const onDisk = fsImpl.existsSync(checklistsDir)
|
|
100
|
+
? fsImpl.readdirSync(checklistsDir).filter((name) => name.endsWith('.md'))
|
|
96
101
|
: [];
|
|
97
102
|
const strays = onDisk.filter((name) => !expected.has(name));
|
|
98
103
|
|
|
@@ -101,29 +106,69 @@ export function buildExpected() {
|
|
|
101
106
|
|
|
102
107
|
/**
|
|
103
108
|
* @param {string} basename — e.g. `security.md`
|
|
109
|
+
* @param {string} [checklistsDir]
|
|
110
|
+
* @param {string} [projectRoot]
|
|
104
111
|
* @returns {string} repo-relative POSIX path for messages.
|
|
105
112
|
*/
|
|
106
|
-
function relChecklist(
|
|
113
|
+
function relChecklist(
|
|
114
|
+
basename,
|
|
115
|
+
checklistsDir = CHECKLISTS_DIR,
|
|
116
|
+
projectRoot = PROJECT_ROOT,
|
|
117
|
+
) {
|
|
107
118
|
return path
|
|
108
|
-
.relative(
|
|
119
|
+
.relative(projectRoot, path.join(checklistsDir, basename))
|
|
109
120
|
.split(path.sep)
|
|
110
121
|
.join('/');
|
|
111
122
|
}
|
|
112
123
|
|
|
113
124
|
/**
|
|
114
|
-
*
|
|
125
|
+
* The generator core, extracted from the CLI shell so both modes — the
|
|
126
|
+
* `--check` drift gate and the write/prune pass — are reachable without
|
|
127
|
+
* touching the real `.agents/audit-checklists` tree.
|
|
128
|
+
*
|
|
129
|
+
* Every seam on the optional final `deps` parameter defaults to the real
|
|
130
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
|
|
131
|
+
* `npm run docs:check` are unchanged.
|
|
132
|
+
*
|
|
133
|
+
* @param {string[]} [argv]
|
|
134
|
+
* @param {{
|
|
135
|
+
* fsImpl?: typeof fs,
|
|
136
|
+
* lenses?: ReadonlyArray<string>,
|
|
137
|
+
* workflowsDir?: string,
|
|
138
|
+
* checklistsDir?: string,
|
|
139
|
+
* projectRoot?: string,
|
|
140
|
+
* logger?: { info: Function },
|
|
141
|
+
* }} [deps]
|
|
142
|
+
* @returns {Promise<{ wrote: number, pruned: number, checked?: boolean }>}
|
|
115
143
|
*/
|
|
116
|
-
async function
|
|
144
|
+
export async function runGenerateLensChecklists(
|
|
145
|
+
argv = process.argv.slice(2),
|
|
146
|
+
deps = {},
|
|
147
|
+
) {
|
|
148
|
+
const {
|
|
149
|
+
fsImpl = fs,
|
|
150
|
+
lenses = AUDIT_LENSES,
|
|
151
|
+
workflowsDir = WORKFLOWS_DIR,
|
|
152
|
+
checklistsDir = CHECKLISTS_DIR,
|
|
153
|
+
projectRoot = PROJECT_ROOT,
|
|
154
|
+
logger = Logger,
|
|
155
|
+
} = deps;
|
|
117
156
|
const { values } = parseArgs({
|
|
118
157
|
args: argv,
|
|
119
158
|
options: { check: { type: 'boolean', default: false } },
|
|
120
159
|
allowPositionals: false,
|
|
121
160
|
});
|
|
122
161
|
|
|
123
|
-
const { expected, missing, strays } = buildExpected(
|
|
162
|
+
const { expected, missing, strays } = buildExpected({
|
|
163
|
+
fsImpl,
|
|
164
|
+
lenses,
|
|
165
|
+
workflowsDir,
|
|
166
|
+
checklistsDir,
|
|
167
|
+
});
|
|
168
|
+
const rel = (basename) => relChecklist(basename, checklistsDir, projectRoot);
|
|
124
169
|
|
|
125
170
|
if (missing.length > 0) {
|
|
126
|
-
|
|
171
|
+
logger.info(
|
|
127
172
|
`generate-lens-checklists: no audit-<lens>.md for: ${missing.join(', ')} — no checklist emitted.`,
|
|
128
173
|
);
|
|
129
174
|
}
|
|
@@ -131,21 +176,21 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
131
176
|
if (values.check) {
|
|
132
177
|
const drifted = [];
|
|
133
178
|
for (const [basename, content] of expected) {
|
|
134
|
-
const target = path.join(
|
|
135
|
-
const original =
|
|
136
|
-
?
|
|
179
|
+
const target = path.join(checklistsDir, basename);
|
|
180
|
+
const original = fsImpl.existsSync(target)
|
|
181
|
+
? fsImpl.readFileSync(target, 'utf8')
|
|
137
182
|
: null;
|
|
138
|
-
if (original !== content) drifted.push(
|
|
183
|
+
if (original !== content) drifted.push(rel(basename));
|
|
139
184
|
}
|
|
140
185
|
if (drifted.length === 0 && strays.length === 0) {
|
|
141
|
-
|
|
186
|
+
logger.info(
|
|
142
187
|
`generate-lens-checklists: ${expected.size} checklist(s) up to date.`,
|
|
143
188
|
);
|
|
144
|
-
return;
|
|
189
|
+
return { wrote: 0, pruned: 0, checked: true };
|
|
145
190
|
}
|
|
146
191
|
const problems = [
|
|
147
192
|
...drifted.map((p) => `out of date: ${p}`),
|
|
148
|
-
...strays.map((s) => `stray (no lens): ${
|
|
193
|
+
...strays.map((s) => `stray (no lens): ${rel(s)}`),
|
|
149
194
|
];
|
|
150
195
|
throw new Error(
|
|
151
196
|
`Lens checklists are out of sync:\n ${problems.join('\n ')}\n` +
|
|
@@ -153,26 +198,32 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
153
198
|
);
|
|
154
199
|
}
|
|
155
200
|
|
|
156
|
-
|
|
201
|
+
fsImpl.mkdirSync(checklistsDir, { recursive: true });
|
|
157
202
|
let wrote = 0;
|
|
158
203
|
for (const [basename, content] of expected) {
|
|
159
|
-
const target = path.join(
|
|
160
|
-
const original =
|
|
161
|
-
?
|
|
204
|
+
const target = path.join(checklistsDir, basename);
|
|
205
|
+
const original = fsImpl.existsSync(target)
|
|
206
|
+
? fsImpl.readFileSync(target, 'utf8')
|
|
162
207
|
: null;
|
|
163
208
|
if (original === content) continue;
|
|
164
|
-
|
|
209
|
+
fsImpl.writeFileSync(target, content, 'utf8');
|
|
165
210
|
wrote += 1;
|
|
166
211
|
}
|
|
167
212
|
for (const stray of strays) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
`generate-lens-checklists: pruned stray ${relChecklist(stray)}`,
|
|
171
|
-
);
|
|
213
|
+
fsImpl.rmSync(path.join(checklistsDir, stray));
|
|
214
|
+
logger.info(`generate-lens-checklists: pruned stray ${rel(stray)}`);
|
|
172
215
|
}
|
|
173
|
-
|
|
216
|
+
logger.info(
|
|
174
217
|
`generate-lens-checklists: wrote ${wrote} of ${expected.size} checklist(s) (${strays.length} pruned).`,
|
|
175
218
|
);
|
|
219
|
+
return { wrote, pruned: strays.length };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {string[]} [argv]
|
|
224
|
+
*/
|
|
225
|
+
async function main(argv = process.argv.slice(2)) {
|
|
226
|
+
await runGenerateLensChecklists(argv);
|
|
176
227
|
}
|
|
177
228
|
|
|
178
229
|
export { CHECKLISTS_DIR, WORKFLOWS_DIR };
|
|
@@ -31,6 +31,9 @@ const HEADING_FINDING = /^###\s+(.+?)\s*$/;
|
|
|
31
31
|
const HEADING_SECTION = /^##\s+(.+?)\s*$/;
|
|
32
32
|
const PATH_HINT =
|
|
33
33
|
/(?<![\w/])([A-Za-z0-9_./\\@-]+\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env))(?![\w])/g;
|
|
34
|
+
const FILE_EXT =
|
|
35
|
+
/\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env)$/;
|
|
36
|
+
const TITLE_ANCHOR = /^\s*`([^`]+)`/;
|
|
34
37
|
|
|
35
38
|
function unwrapInlineCode(value) {
|
|
36
39
|
if (typeof value !== 'string') return '';
|
|
@@ -81,17 +84,81 @@ function deriveDimension(fields, fallbackDimension) {
|
|
|
81
84
|
* @param {Record<string, string>} fields
|
|
82
85
|
* @returns {string[]}
|
|
83
86
|
*/
|
|
84
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Normalise a raw path token to a repo-relative path, or `null` when the
|
|
89
|
+
* token is not a usable file reference.
|
|
90
|
+
*
|
|
91
|
+
* Three shapes historically leaked through and poisoned grouping downstream:
|
|
92
|
+
* an **absolute** path (which can never match a repo-relative group key), a
|
|
93
|
+
* **degenerate** token such as a bare `/`, and — most damagingly — a
|
|
94
|
+
* **root-level** file such as `AGENTS.md`, which the old slash-only guard
|
|
95
|
+
* discarded outright even when it was the finding's explicit `Location:`.
|
|
96
|
+
*
|
|
97
|
+
* `requireSeparator` is what keeps that third fix from over-reaching. In the
|
|
98
|
+
* **structured** fields — the title anchor and `Location:` — a bare
|
|
99
|
+
* `AGENTS.md` is unambiguously a file, so the separator is not required. In
|
|
100
|
+
* free **prose**, a bare `description.md` is far more likely to be a word than
|
|
101
|
+
* a path, so the separator stays mandatory there.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} raw — the token as it appeared in the report.
|
|
104
|
+
* @param {string} [repoRoot] — absolute repo root; absolute tokens beneath it
|
|
105
|
+
* are relativised. Omitted (the pure default) leaves absolutes to be dropped.
|
|
106
|
+
* @param {{ requireSeparator?: boolean }} [options]
|
|
107
|
+
* @returns {string|null}
|
|
108
|
+
*/
|
|
109
|
+
function normalisePathToken(raw, repoRoot, { requireSeparator = false } = {}) {
|
|
110
|
+
if (typeof raw !== 'string') return null;
|
|
111
|
+
const stripped = raw
|
|
112
|
+
.replace(/^[`'"([]+/, '')
|
|
113
|
+
.replace(/[`'")\].,;]+$/, '')
|
|
114
|
+
.trim();
|
|
115
|
+
// `Location:` anchors appear as `:line`, `:line:col`, and `:start-end`.
|
|
116
|
+
const cleaned = stripped.replace(/:\d+(?:-\d+)?(?::\d+)?$/, '');
|
|
117
|
+
if (!cleaned || cleaned === '.' || cleaned === '..') return null;
|
|
118
|
+
|
|
119
|
+
let out = cleaned;
|
|
120
|
+
if (typeof repoRoot === 'string' && repoRoot.length > 0) {
|
|
121
|
+
for (const sep of ['/', '\\']) {
|
|
122
|
+
const root = repoRoot.endsWith(sep) ? repoRoot : `${repoRoot}${sep}`;
|
|
123
|
+
if (out.startsWith(root)) {
|
|
124
|
+
out = out.slice(root.length);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// A path that is still absolute lives outside the repo — it can never be a
|
|
130
|
+
// valid group key, so drop it rather than let it become one.
|
|
131
|
+
if (out.startsWith('/') || out.startsWith('\\') || /^[A-Za-z]:/.test(out)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const hasSeparator = out.includes('/') || out.includes('\\');
|
|
135
|
+
if (requireSeparator && !hasSeparator) return null;
|
|
136
|
+
if (!hasSeparator && !FILE_EXT.test(out)) return null;
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The finding-block skeleton mandates that a title lead with the primary file
|
|
142
|
+
* the finding lives in (``### `path/to/file.ext` — title``). That anchor is
|
|
143
|
+
* the most reliable primary-file signal a block carries, so it seeds `files[]`
|
|
144
|
+
* ahead of `Location:` and prose scraping — `pickPrimaryFile` takes `files[0]`.
|
|
145
|
+
*/
|
|
146
|
+
function deriveTitleFile(title, repoRoot) {
|
|
147
|
+
const match = TITLE_ANCHOR.exec(typeof title === 'string' ? title : '');
|
|
148
|
+
if (!match) return [];
|
|
149
|
+
const normalised = normalisePathToken(match[1], repoRoot);
|
|
150
|
+
return normalised ? [normalised] : [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function deriveLocationFiles(fields, repoRoot) {
|
|
85
154
|
const raw = fields.location;
|
|
86
155
|
if (typeof raw !== 'string' || raw.trim().length === 0) return [];
|
|
87
156
|
const cleaned = raw.replace(/[`[\]]/g, ' ');
|
|
88
157
|
const out = [];
|
|
89
158
|
for (const token of cleaned.split(/[\s,]+/)) {
|
|
90
159
|
if (!token) continue;
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
out.push(withoutLine);
|
|
94
|
-
}
|
|
160
|
+
const normalised = normalisePathToken(token, repoRoot);
|
|
161
|
+
if (normalised) out.push(normalised);
|
|
95
162
|
}
|
|
96
163
|
return out;
|
|
97
164
|
}
|
|
@@ -113,14 +180,15 @@ function normaliseTitle(title) {
|
|
|
113
180
|
.trim();
|
|
114
181
|
}
|
|
115
182
|
|
|
116
|
-
function extractFilePaths(text) {
|
|
183
|
+
function extractFilePaths(text, repoRoot) {
|
|
117
184
|
if (typeof text !== 'string') return [];
|
|
118
185
|
const seen = new Set();
|
|
119
186
|
for (const match of text.matchAll(PATH_HINT)) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
187
|
+
// Prose: a bare `description.md` is more likely a word than a path.
|
|
188
|
+
const normalised = normalisePathToken(match[1], repoRoot, {
|
|
189
|
+
requireSeparator: true,
|
|
190
|
+
});
|
|
191
|
+
if (normalised) seen.add(normalised);
|
|
124
192
|
}
|
|
125
193
|
return [...seen];
|
|
126
194
|
}
|
|
@@ -211,7 +279,7 @@ function parseBlockFields(bodyLines) {
|
|
|
211
279
|
* sourceReport: string,
|
|
212
280
|
* }>}
|
|
213
281
|
*/
|
|
214
|
-
export function parseAuditReport({ markdown, sourceReport }) {
|
|
282
|
+
export function parseAuditReport({ markdown, sourceReport, repoRoot }) {
|
|
215
283
|
if (typeof markdown !== 'string') {
|
|
216
284
|
throw new Error('parseAuditReport: markdown must be a string');
|
|
217
285
|
}
|
|
@@ -231,10 +299,11 @@ export function parseAuditReport({ markdown, sourceReport }) {
|
|
|
231
299
|
fields['recommendation & rationale'] ?? fields.recommendation ?? '';
|
|
232
300
|
const agentPrompt = fields['agent prompt'] ?? '';
|
|
233
301
|
const fileSet = new Set([
|
|
234
|
-
...
|
|
235
|
-
...
|
|
236
|
-
...extractFilePaths(
|
|
237
|
-
...extractFilePaths(
|
|
302
|
+
...deriveTitleFile(block.title, repoRoot),
|
|
303
|
+
...deriveLocationFiles(fields, repoRoot),
|
|
304
|
+
...extractFilePaths(currentState, repoRoot),
|
|
305
|
+
...extractFilePaths(recommendation, repoRoot),
|
|
306
|
+
...extractFilePaths(agentPrompt, repoRoot),
|
|
238
307
|
]);
|
|
239
308
|
|
|
240
309
|
return {
|
|
@@ -258,15 +327,17 @@ export function parseAuditReport({ markdown, sourceReport }) {
|
|
|
258
327
|
* an audit can legitimately come back empty.
|
|
259
328
|
*
|
|
260
329
|
* @param {Array<{ markdown: string, sourceReport: string }>} reports
|
|
330
|
+
* @param {{ repoRoot?: string }} [options] — `repoRoot` relativises absolute
|
|
331
|
+
* paths quoted in a report so they can match repo-relative group keys.
|
|
261
332
|
* @returns {Array<ReturnType<typeof parseAuditReport>[number]>}
|
|
262
333
|
*/
|
|
263
|
-
export function parseAuditReports(reports) {
|
|
334
|
+
export function parseAuditReports(reports, { repoRoot } = {}) {
|
|
264
335
|
if (!Array.isArray(reports)) {
|
|
265
336
|
throw new Error('parseAuditReports: reports must be an array');
|
|
266
337
|
}
|
|
267
338
|
const out = [];
|
|
268
339
|
for (const report of reports) {
|
|
269
|
-
out.push(...parseAuditReport(report));
|
|
340
|
+
out.push(...parseAuditReport({ ...report, repoRoot }));
|
|
270
341
|
}
|
|
271
342
|
return out;
|
|
272
343
|
}
|