@vibecompass/vibecompass 0.15.0 → 0.16.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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Markdown rendering for the session brief (plan task A3). Pure functions of
3
+ * the selection result from `brief.js`: the engine measures units with
4
+ * `renderBriefUnit` while packing and the whole brief with `renderBrief`, so
5
+ * the estimated size always describes exactly the text a reader gets.
6
+ *
7
+ * Wording rule (D-359): relations are named by their evidence — "covers" and
8
+ * "cites" are mechanical, "supersedes" and "amends" are declared by the later
9
+ * entry. The brief's own text never says that a decision governs something
10
+ * or is currently valid.
11
+ */
12
+
13
+ const LIMITS = {
14
+ description: 600,
15
+ guidance: 450,
16
+ decision: 650,
17
+ impact: 450,
18
+ successorDecision: 450,
19
+ successorImpact: 300,
20
+ next: 350,
21
+ handoff: 350,
22
+ reasons: 240,
23
+ watchItems: 5,
24
+ };
25
+
26
+ const EVIDENCE_NOTE =
27
+ '_Evidence: "covers" and "cites" are mechanical (the doc lists the file or mentions the decision); "supersedes" and "amends" come only from the later decision\'s own text. The brief infers no other relation and does not decide which decision applies to this task._';
28
+ const EVIDENCE_NOTE_SHORT = '_Evidence: covers/cites are mechanical; supersedes/amends are declared by the later decision. Nothing else is inferred._';
29
+
30
+ /**
31
+ * Frame limits (header, status, follow-ups) per compaction level. Level 0 is
32
+ * normal; the engine raises the level only when the frame itself would not
33
+ * fit the budget, so every accepted input renders within budget. Every
34
+ * field is bounded at every level; units render the same at every level.
35
+ */
36
+ const FRAME_LIMITS = [
37
+ { task: 300, file: 120, files: 4, root: 160, reason: 300, gap: 240, followUpPath: 120, followUpHeading: 60, followUpReason: 170, followUps: Infinity, evidence: EVIDENCE_NOTE },
38
+ { task: 160, file: 60, files: 2, root: 60, reason: 200, gap: 160, followUpPath: 100, followUpHeading: 40, followUpReason: 80, followUps: Infinity, evidence: EVIDENCE_NOTE_SHORT },
39
+ { task: 120, file: 50, files: 1, root: 40, reason: 160, gap: 110, followUpPath: 80, followUpHeading: 30, followUpReason: 40, followUps: Infinity, evidence: EVIDENCE_NOTE_SHORT },
40
+ { task: 100, file: 40, files: 1, root: 30, reason: 120, gap: 90, followUpPath: 70, followUpHeading: 24, followUpReason: 30, followUps: 6, evidence: EVIDENCE_NOTE_SHORT },
41
+ ];
42
+ export const BRIEF_MAX_COMPACTION = FRAME_LIMITS.length - 1;
43
+
44
+ export function renderBrief(result) {
45
+ const frame = FRAME_LIMITS[Math.min(result.render?.compaction ?? 0, BRIEF_MAX_COMPACTION)];
46
+ const lines = ['# Session brief', ''];
47
+ const followUps = renderFollowUpList(result, frame);
48
+
49
+ if (result.status === 'incomplete') {
50
+ lines.push('**INCOMPLETE — read these before planning:**', ...followUps, '');
51
+ }
52
+
53
+ lines.push(`**Status: ${result.status}** — ${statusSentence(result, frame)}`, '');
54
+ lines.push(...renderHeader(result, frame), '');
55
+
56
+ if (result.status !== 'no-match') lines.push(frame.evidence, '');
57
+
58
+ const byKind = (kind) => result.units.filter((unit) => unit.kind === kind);
59
+ const emitted = new Set();
60
+
61
+ for (const unit of byKind('lane')) lines.push(renderBriefUnit(unit, { emittedDecisions: emitted }), '');
62
+
63
+ const lineage = byKind('lineage');
64
+ if (lineage.length > 0) {
65
+ lines.push('## Decisions', '');
66
+ for (const unit of lineage) {
67
+ lines.push(renderBriefUnit(unit, { emittedDecisions: emitted }), '');
68
+ for (const member of unit.members) emitted.add(member.decision_id);
69
+ }
70
+ }
71
+
72
+ const docs = byKind('doc');
73
+ if (docs.length > 0) {
74
+ lines.push('## Architecture docs', '');
75
+ for (const unit of docs) lines.push(renderBriefUnit(unit), '');
76
+ }
77
+
78
+ const notes = byKind('note');
79
+ if (notes.length > 0) {
80
+ lines.push('## Session notes', '');
81
+ for (const unit of notes) lines.push(renderBriefUnit(unit), '');
82
+ }
83
+
84
+ const watchOuts = byKind('watch-out');
85
+ if (watchOuts.length > 0) {
86
+ lines.push('## Watch-outs', '');
87
+ for (const unit of watchOuts) lines.push(renderBriefUnit(unit));
88
+ lines.push('');
89
+ }
90
+
91
+ if (result.status !== 'incomplete' && followUps.length > 0) {
92
+ lines.push('## Follow-up reads', '', ...followUps, '');
93
+ }
94
+
95
+ return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
96
+ }
97
+
98
+ /** One unit's markdown; `emittedDecisions` holds decisions shown by earlier units. */
99
+ export function renderBriefUnit(unit, options = {}) {
100
+ switch (unit.kind) {
101
+ case 'lane':
102
+ return renderLane(unit);
103
+ case 'lineage':
104
+ return renderLineage(unit, options.emittedDecisions ?? new Set());
105
+ case 'doc':
106
+ return renderDoc(unit);
107
+ case 'note':
108
+ return renderNote(unit);
109
+ case 'watch-out':
110
+ return renderWatchOut(unit);
111
+ default:
112
+ throw new Error(`Unknown brief unit kind "${unit.kind}".`);
113
+ }
114
+ }
115
+
116
+ function statusSentence(result, frame) {
117
+ const withheld = result.omitted.filter((unit) => unit.omitted_reason === 'narrow-match').length;
118
+ const overflow = result.omitted.length - withheld;
119
+ switch (result.status) {
120
+ case 'complete':
121
+ return 'every selected unit fits the budget.';
122
+ case 'partial': {
123
+ const parts = [];
124
+ if (overflow > 0) parts.push(`${overflow} lower-tier unit${overflow === 1 ? '' : 's'} did not fit`);
125
+ if (withheld > 0) {
126
+ // Every unit in a narrow match is low-confidence, the packed ones included.
127
+ parts.push(`most of the task's distinctive words had no keyword match, so every match is low-confidence: the top ${result.units.filter((unit) => unit.tier === 'ranked').length} are shown for orientation and ${withheld} more ${withheld === 1 ? 'is' : 'are'} listed as reads only`);
128
+ }
129
+ return `${parts.join('; ')}; see Follow-up reads.`;
130
+ }
131
+ case 'no-match':
132
+ return 'no architecture doc or decision matched this task. Nothing in memory is presented as relevant; say so in your plan, and read the orientation overview if the task needs broader context.';
133
+ default:
134
+ return `${excerpt(result.status_reason, frame.reason)}.`;
135
+ }
136
+ }
137
+
138
+ function renderHeader(result, frame) {
139
+ const files = result.inputs.files;
140
+ const fileText =
141
+ files.length === 0
142
+ ? 'none'
143
+ : `${files.slice(0, frame.files).map((file) => `\`${elide(file, frame.file)}\``).join(', ')}${files.length > frame.files ? ` (+${files.length - frame.files} more)` : ''}`;
144
+ const lines = [
145
+ `- Task: ${excerpt(result.task, frame.task) || '(none)'}`,
146
+ `- Files: ${fileText}`,
147
+ `- Lane: ${result.source.lane_id ? `\`${result.source.lane_id}\`` : 'none'} · Root: \`${elide(result.source.root_dir, frame.root)}\``,
148
+ `- Estimated size: ~${groupDigits(result.budget.estimated_tokens)} of ${groupDigits(result.budget.limit)} tokens (Unicode code points ÷ 4 over this whole brief)`,
149
+ ];
150
+ if (result.orientation.exists) {
151
+ lines.push(`- Orientation: \`${result.orientation.path}\` — the whole-project overview; read it first when orienting.`);
152
+ }
153
+ for (const gap of result.gaps) lines.push(`- Gap: ${excerpt(gap.message, frame.gap)}`);
154
+ const headerElided =
155
+ files.slice(0, frame.files).some((file) => elide(file, frame.file) !== file) || elide(result.source.root_dir, frame.root) !== result.source.root_dir;
156
+ if (headerElided) lines.push('- Shortened values (…) are exact in `--json` (`inputs`, `source`).');
157
+ return lines;
158
+ }
159
+
160
+ /** How many follow-up reads the rendered brief lists (the cap, tightened by the frame level). */
161
+ export function renderedFollowUpCount(result) {
162
+ const frame = FRAME_LIMITS[Math.min(result.render?.compaction ?? 0, BRIEF_MAX_COMPACTION)];
163
+ return Math.min(result.follow_ups.length, result.follow_up_cap, frame.followUps);
164
+ }
165
+
166
+ function renderFollowUpList(result, frame) {
167
+ const shown = result.follow_ups.slice(0, renderedFollowUpCount(result));
168
+ let elided = false;
169
+ const lines = shown.map((entry) => {
170
+ const heading = entry.heading ? ` › ${excerpt(entry.heading, frame.followUpHeading)}` : '';
171
+ const shownPath = elide(entry.path, frame.followUpPath);
172
+ if (shownPath !== entry.path) elided = true;
173
+ return `${entry.rank}. \`${shownPath}\`${heading} — ${excerpt(entry.reason, frame.followUpReason)}`;
174
+ });
175
+ const hidden = result.follow_ups.length - shown.length;
176
+ if (hidden > 0) lines.push(`+${hidden} more (full list in \`--json\`)`);
177
+ if (elided) lines.push('_Paths shortened with … are exact in `--json` (`follow_ups[].path`)._');
178
+ return lines;
179
+ }
180
+
181
+ /** Middle-elided text (paths): keeps both ends within `limit` code points. */
182
+ function elide(value, limit) {
183
+ const points = [...String(value ?? '')];
184
+ if (points.length <= limit) return points.join('');
185
+ const head = Math.ceil((limit - 1) / 3);
186
+ const tail = limit - 1 - head;
187
+ return `${points.slice(0, head).join('')}…${points.slice(points.length - tail).join('')}`;
188
+ }
189
+
190
+ function renderLane(unit) {
191
+ const lines = [`## Lane \`${unit.lane_id}\``];
192
+ lines.push(`- Working on: ${excerpt(unit.working_on, LIMITS.handoff) || 'not recorded'}`);
193
+ if (unit.handoff_exists) {
194
+ lines.push(`- Handoff, what's next: ${excerpt(flattenList(unit.handoff_next), LIMITS.handoff) || 'not recorded'}`);
195
+ lines.push(`- Handoff, open review: ${excerpt(flattenList(unit.open_review), LIMITS.handoff) || 'none recorded'}`);
196
+ lines.push(`- Required read: \`${unit.handoff_path}\` (full)`);
197
+ } else {
198
+ lines.push(`- Handoff: none at \`${unit.handoff_path}\``);
199
+ }
200
+ return lines.join('\n');
201
+ }
202
+
203
+ function renderLineage(unit, emittedDecisions) {
204
+ const [root, ...successors] = unit.members;
205
+ const lines = [];
206
+ if (emittedDecisions.has(root.decision_id)) {
207
+ lines.push(`### ${formatId(root.decision_id)} — shown above`);
208
+ } else {
209
+ lines.push(`### ${formatId(root.decision_id)} — ${root.title ?? 'untitled'}`);
210
+ lines.push(`\`${root.path}\`${root.date ? ` · ${root.date}` : ''}${reasonText(unit.reasons)}`);
211
+ lines.push(`- Decision: ${excerpt(root.decision, LIMITS.decision) || 'not recorded'}`);
212
+ if (root.impact) lines.push(`- Impact on prior decisions: ${excerpt(root.impact, LIMITS.impact)}`);
213
+ }
214
+
215
+ if (successors.length > 0) {
216
+ lines.push('- Declared successors (transitive), newest first:');
217
+ for (const member of successors) {
218
+ const declared = describeDeclared(member);
219
+ if (emittedDecisions.has(member.decision_id)) {
220
+ lines.push(` - ${formatId(member.decision_id)} ${declared} — shown above`);
221
+ continue;
222
+ }
223
+ lines.push(` - **${formatId(member.decision_id)}** ${declared} — ${member.title ?? 'untitled'} (\`${member.path}\`${member.date ? `, ${member.date}` : ''})`);
224
+ lines.push(` - Decision: ${excerpt(member.decision, LIMITS.successorDecision) || 'not recorded'}`);
225
+ if (member.impact) lines.push(` - Impact on prior decisions: ${excerpt(member.impact, LIMITS.successorImpact)}`);
226
+ }
227
+ }
228
+
229
+ const byPath = new Map();
230
+ for (const member of unit.members) {
231
+ if (!byPath.has(member.path)) byPath.set(member.path, []);
232
+ byPath.get(member.path).push(formatId(member.decision_id));
233
+ }
234
+ lines.push(`- Full entries and rationale: ${[...byPath].map(([entryPath, ids]) => `\`${entryPath}\` › ${ids.join(', ')}`).join('; ')}`);
235
+ return lines.join('\n');
236
+ }
237
+
238
+ function describeDeclared(member) {
239
+ if (member.declared_relations.length === 0) {
240
+ return `(declared ${member.successor_relations.join(', ')})`;
241
+ }
242
+ const parts = member.declared_relations.map((relation) => {
243
+ const verb =
244
+ relation.relation === 'supersedes' && relation.extent === 'partial'
245
+ ? 'partially supersedes'
246
+ : relation.relation;
247
+ // A partial or scoped relation keeps the part it names: a structured
248
+ // `**Partially supersedes:** D-100 — the credit balance` may be the only
249
+ // place that part is stated (D-363).
250
+ const scope = relation.scope && (relation.extent === 'scoped' || relation.extent === 'partial')
251
+ ? ` (${relation.extent === 'partial' ? 'part' : 'scoped'}: ${excerpt(relation.scope, 80)})`
252
+ : '';
253
+ return `${verb} ${formatId(relation.target_decision_id)}${scope}`;
254
+ });
255
+ return `(declared: ${[...new Set(parts)].join('; ')})`;
256
+ }
257
+
258
+ function renderDoc(unit) {
259
+ const lines = [`### \`${unit.path}\``];
260
+ lines.push(`${unit.domain} › ${unit.feature} › ${unit.component} · frontmatter status: ${unit.status ?? 'unknown'}${reasonText(unit.reasons)}`);
261
+ lines.push(`- Description: ${excerpt(unit.description, LIMITS.description) || 'missing'}`);
262
+ if (unit.retrieval_guidance) {
263
+ lines.push(`- Retrieval guidance: ${excerpt(unit.retrieval_guidance, LIMITS.guidance)}`);
264
+ } else if (unit.retrieval_scope) {
265
+ lines.push(`- Retrieval scope: ${excerpt(unit.retrieval_scope, LIMITS.guidance)}`);
266
+ }
267
+ if (unit.missing_sections.length > 0) {
268
+ lines.push(
269
+ `- Maintenance: missing ${unit.missing_sections.map((section) => `\`## ${section}\``).join(', ')} (\`architecture-missing-section\`) — add ${unit.missing_sections.length === 1 ? 'it' : 'them'} so briefs can route to this doc precisely.`,
270
+ );
271
+ }
272
+ return lines.join('\n');
273
+ }
274
+
275
+ function renderNote(unit) {
276
+ const lines = [`### \`${unit.path}\` — ${unit.title ?? 'untitled'}${unit.date ? ` (${unit.date})` : ''}`];
277
+ lines.push(`selected: ${unit.reasons.join('; ')}`);
278
+ lines.push(`- Next session should start with: ${excerpt(unit.next_session, LIMITS.next) || 'not recorded'}`);
279
+ return lines.join('\n');
280
+ }
281
+
282
+ function renderWatchOut(unit) {
283
+ const items = unit.items.slice(0, LIMITS.watchItems);
284
+ const more = unit.items.length - items.length;
285
+ const suffix = more > 0 ? `; +${more} more` : '';
286
+ if (unit.category === 'other-lanes') {
287
+ const text = items
288
+ .map((item) => {
289
+ const shown = item.overlapping_claims.slice(0, 3).map((claim) => `\`${elide(claim, 80)}\``).join(', ');
290
+ const extra = item.overlapping_claims.length > 3 ? ` (+${item.overlapping_claims.length - 3} more)` : '';
291
+ const overlap = item.overlapping_claims.length > 0 ? `; claims overlapping this lane: ${shown}${extra}` : '';
292
+ return `\`${item.lane_id}\` (${excerpt(item.working_on, 90) || 'no summary'}${overlap})`;
293
+ })
294
+ .join('; ');
295
+ return `- Other active lanes: ${text}${suffix}`;
296
+ }
297
+ if (unit.category === 'newer-decisions') {
298
+ return `- Decisions appended after this lane's snapshot ${formatId(unit.snapshot)}: ${items
299
+ .map((item) => `${formatId(item.decision_id)} — ${excerpt(item.title, 90)} (\`${item.path}\`)`)
300
+ .join('; ')}${suffix}`;
301
+ }
302
+ return `- Lane bindings missing on disk: ${items.map((item) => `worktree for \`${item.repo}\` at \`${elide(item.path, 100)}\``).join('; ')}${suffix}`;
303
+ }
304
+
305
+ /** "- a\n- b" → "a; b": list items read inline. */
306
+ function flattenList(value) {
307
+ if (!value) return value;
308
+ return String(value)
309
+ .split(/\r?\n/)
310
+ .map((line) => line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, '').trim())
311
+ .filter(Boolean)
312
+ .reduce((joined, item) => (joined ? `${joined}${/[.!?]$/.test(joined) ? ' ' : '; '}${item}` : item), '');
313
+ }
314
+
315
+ function reasonText(reasons) {
316
+ if (!reasons || reasons.length === 0) return '';
317
+ return ` · selected: ${excerpt(reasons.join('; '), LIMITS.reasons)}`;
318
+ }
319
+
320
+ /** Whitespace-collapsed text cut to `limit` code points at a sentence or word edge. */
321
+ export function excerpt(value, limit) {
322
+ if (value === null || value === undefined) return '';
323
+ const text = String(value).replace(/\s+/g, ' ').trim();
324
+ const points = [...text];
325
+ if (points.length <= limit) return text;
326
+ const marker = ' …[cut]';
327
+ const room = limit - [...marker].length;
328
+ const head = points.slice(0, room).join('');
329
+ const sentence = Math.max(head.lastIndexOf('. '), head.lastIndexOf('; '));
330
+ const word = head.lastIndexOf(' ');
331
+ const cut = sentence >= room * 0.6 ? sentence + 1 : word > room * 0.5 ? word : room;
332
+ return `${head.slice(0, cut).trimEnd()}${marker}`;
333
+ }
334
+
335
+ function groupDigits(value) {
336
+ return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
337
+ }
338
+
339
+ function formatId(id) {
340
+ return `D-${String(id).padStart(3, '0')}`;
341
+ }
@@ -0,0 +1,160 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseSimpleYaml } from './simple-yaml.js';
4
+
5
+ /**
6
+ * `project.yaml` `brief:` settings (D-364, D-368): `enabled: true` opts a root
7
+ * into lifecycle generation (it is off by default), and `exclude` names
8
+ * canonical documents the session brief must never read. Exclusion fails closed: a malformed or unknown setting is a
9
+ * problem that stops the brief from reading any canonical document, because a
10
+ * silent fallback would expose exactly the files the setting exists to hide.
11
+ */
12
+
13
+ const KNOWN_BRIEF_FIELDS = new Set(['enabled', 'exclude']);
14
+ const UNSUPPORTED_GLOB_CHARACTERS = /[[\]{}\\]/;
15
+
16
+ /**
17
+ * Validates a `brief:` value from `project.yaml`. Returns the effective
18
+ * settings plus `problems`; any problem means the settings must not be used.
19
+ */
20
+ export function validateBriefSettings(value) {
21
+ const settings = { enabled: false, enabledDeclared: false, exclude: [], problems: [] };
22
+ if (value === undefined || value === null) {
23
+ return settings;
24
+ }
25
+
26
+ if (typeof value !== 'object' || Array.isArray(value)) {
27
+ settings.problems.push('"brief" must be a mapping with optional "enabled" and "exclude" fields');
28
+ return settings;
29
+ }
30
+
31
+ for (const key of Object.keys(value)) {
32
+ if (!KNOWN_BRIEF_FIELDS.has(key)) {
33
+ settings.problems.push(`unknown field "brief.${key}" (known: enabled, exclude)`);
34
+ }
35
+ }
36
+
37
+ if (value.enabled !== undefined) {
38
+ if (typeof value.enabled === 'boolean') {
39
+ settings.enabled = value.enabled;
40
+ settings.enabledDeclared = true;
41
+ } else {
42
+ settings.problems.push('"brief.enabled" must be true or false');
43
+ }
44
+ }
45
+
46
+ if (value.exclude !== undefined) {
47
+ if (!Array.isArray(value.exclude)) {
48
+ settings.problems.push('"brief.exclude" must be a list of root-relative glob patterns');
49
+ } else {
50
+ for (const [index, pattern] of value.exclude.entries()) {
51
+ const problem = validateExcludePattern(pattern);
52
+ if (problem) {
53
+ settings.problems.push(`brief.exclude[${index}] ${problem}`);
54
+ } else {
55
+ settings.exclude.push(pattern.trim());
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ settings.exclude = [...new Set(settings.exclude)];
62
+ return settings;
63
+ }
64
+
65
+ /**
66
+ * A pattern is a memory-root-relative POSIX path glob: `*` matches within one
67
+ * path segment, `**` (a whole segment) matches any number of segments, and
68
+ * `?` matches one character. Returns a problem string, or null when valid.
69
+ */
70
+ export function validateExcludePattern(pattern) {
71
+ if (typeof pattern !== 'string' || pattern.trim() === '') {
72
+ return 'must be a non-empty string';
73
+ }
74
+
75
+ const trimmed = pattern.trim();
76
+ if (trimmed.startsWith('/') || /^[A-Za-z]:/.test(trimmed)) {
77
+ return `"${trimmed}" must be relative to the memory root`;
78
+ }
79
+ if (UNSUPPORTED_GLOB_CHARACTERS.test(trimmed) || trimmed.startsWith('!')) {
80
+ return `"${trimmed}" uses unsupported syntax (only *, **, and ? are supported, with no negation; separate segments with /)`;
81
+ }
82
+
83
+ const segments = trimmed.split('/');
84
+ for (const segment of segments) {
85
+ if (segment === '') {
86
+ return `"${trimmed}" has an empty path segment`;
87
+ }
88
+ if (segment === '.' || segment === '..') {
89
+ return `"${trimmed}" must not contain "." or ".." segments`;
90
+ }
91
+ if (segment.includes('**') && segment !== '**') {
92
+ return `"${trimmed}" uses "**" inside a segment; "**" must be a whole segment`;
93
+ }
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Compiles validated patterns into a path predicate over root-relative POSIX
101
+ * paths. Matching ignores case: on a case-insensitive filesystem a mis-cased
102
+ * pattern must still exclude, and over-exclusion is the safe failure.
103
+ */
104
+ export function compileBriefExclusions(patterns = []) {
105
+ const expressions = patterns.map((pattern) => new RegExp(globToRegexSource(pattern), 'i'));
106
+ return {
107
+ patterns: [...patterns],
108
+ matches(relativePath) {
109
+ const normalized = String(relativePath).split(path.sep).join('/');
110
+ return expressions.some((expression) => expression.test(normalized));
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Reads the `brief:` settings of a memory root before any canonical document
117
+ * is read. An unreadable or unparseable `project.yaml` is a problem: without
118
+ * it the brief cannot know what it must not read.
119
+ */
120
+ export async function readBriefSettingsForRoot(rootDir) {
121
+ const projectPath = path.join(rootDir, 'project.yaml');
122
+ let data;
123
+ try {
124
+ data = parseSimpleYaml(await readFile(projectPath, 'utf8'), { sourceName: 'project.yaml' });
125
+ } catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ return {
128
+ enabled: false,
129
+ enabledDeclared: false,
130
+ exclude: [],
131
+ problems: [`project.yaml could not be read (${message.replace(/\s+/g, ' ').slice(0, 200)})`],
132
+ repos: [],
133
+ };
134
+ }
135
+
136
+ const settings = validateBriefSettings(data && typeof data === 'object' ? data.brief : undefined);
137
+ return { ...settings, repos: Array.isArray(data?.repos) ? data.repos : [] };
138
+ }
139
+
140
+ function globToRegexSource(pattern) {
141
+ const segments = pattern.split('/');
142
+ let source = '^';
143
+ for (const [index, segment] of segments.entries()) {
144
+ const last = index === segments.length - 1;
145
+ if (segment === '**') {
146
+ source += last ? '(?:[^/]+/)*[^/]+' : '(?:[^/]+/)*';
147
+ continue;
148
+ }
149
+ source += segment
150
+ .split('')
151
+ .map((character) => {
152
+ if (character === '*') return '[^/]*';
153
+ if (character === '?') return '[^/]';
154
+ return character.replace(/[.+^$()|]/g, '\\$&');
155
+ })
156
+ .join('');
157
+ if (!last) source += '/';
158
+ }
159
+ return `${source}$`;
160
+ }