File size: 6,050 Bytes
a6b96c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
"use strict";
/**
 * Shared parser for CONTEXT.md <decisions> blocks (ADR-457 build-at-publish:
 * the hand-written bin/lib/decisions.cjs collapsed to a TypeScript source of
 * truth). Behaviour is preserved byte-for-behaviour from the prior hand-written
 * .cjs; only types are added.
 *
 * Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
 * Returns {id, text, category, tags, trackable} per decision.
 * CJS callers that only use {id, text} safely ignore the extra fields.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseDecisions = parseDecisions;
const DISCRETION_HEADINGS = new Set([
    "claude's discretion",
    'claudes discretion',
    'claude discretion',
]);
const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
/**
 * Strip fenced code blocks from `content` so example `<decisions>` snippets
 * inside ```` ``` ```` do not pollute the parser (review F11).
 */
function stripFencedCode(content) {
    return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' ');
}
/**
 * Extract the inner text of EVERY `<decisions>...</decisions>` block in
 * order, concatenated by `\n\n`. Returns null when no block is present.
 *
 * CONTEXT.md may legitimately contain more than one block (for example, a
 * "current decisions" block plus a "carry-over from prior phase" block);
 * dropping all-but-the-first silently lost the second batch (review F13).
 */
function extractDecisionsBlock(content) {
    const cleaned = stripFencedCode(content);
    const matches = [...cleaned.matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)];
    if (matches.length === 0)
        return null;
    return matches.map((m) => m[1]).join('\n\n');
}
/**
 * Parse trackable decisions from CONTEXT.md content.
 *
 * Returns ALL D-NN decisions found inside `<decisions>` (including
 * non-trackable ones, with `trackable: false`). Callers that only want the
 * gate-enforced decisions should filter `.filter(d => d.trackable)`.
 */
function parseDecisions(content) {
    if (!content || typeof content !== 'string')
        return [];
    const block = extractDecisionsBlock(content);
    if (block === null)
        return [];
    const lines = block.split(/\r?\n/);
    const out = [];
    let category = '';
    let inDiscretion = false;
    // Bullet line: `- **D-NN[ [tags]]:** text`
    // Phase 6 (#3575): aligned to CJS regex β€” accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
    // in addition to numeric-only IDs (D-42). The first character after `D-` must
    // be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
    // CJS callers consume {id, text} and ignore the optional extras.
    // #1343: `[^:*]*` replaces the old `\s*` before `:**` so that a freeform run
    // such as `(parenthetical)`, an em-dash, or other prose between the optional
    // bracket-tag group and the closing `:**` is tolerated rather than silently
    // dropping the whole decision. `[^:*]*` subsumes plain whitespace and stops
    // correctly at `:**`. Capture groups 1 (id), 2 (bracket tags), 3 (text) are
    // unchanged.
    const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
    let current = null;
    const flush = () => {
        if (current) {
            current.text = current.text.trim();
            out.push(current);
            current = null;
        }
    };
    for (const line of lines) {
        const trimmed = line.trim();
        // Track category headings (`### Heading`)
        const headingMatch = trimmed.match(/^###\s+(.+?)\s*$/);
        if (headingMatch) {
            flush();
            category = headingMatch[1];
            // Strip the full unicode-quote family so any rendering of "Claude's
            // Discretion" (ASCII apostrophe, curly U+2019, U+2018, U+201A, U+201B,
            // double-quote variants U+201C/D/E/F, etc.) collapses to the same key
            // (review F20).
            const normalized = category
                .toLowerCase()
                .replace(/[β€˜β€™β€šβ€›β€œβ€β€žβ€Ÿ'"`]/g, '')
                .trim();
            inDiscretion = DISCRETION_HEADINGS.has(normalized);
            continue;
        }
        const bulletMatch = line.match(bulletRe);
        if (bulletMatch) {
            flush();
            const id = `D-${bulletMatch[1]}`;
            const tags = bulletMatch[2]
                ? bulletMatch[2]
                    .split(',')
                    .map((t) => t.trim().toLowerCase())
                    .filter(Boolean)
                : [];
            const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
            current = { id, text: bulletMatch[3], category, tags, trackable };
            continue;
        }
        // Parse-miss guard (#1343): a line that looks like a `D-NN` decision bullet
        // but failed `bulletRe` (e.g. a `:` or `*` inside the pre-colon run) must NOT
        // be silently dropped β€” a narrowed trackable set lets a blocking coverage gate
        // report a false pass. Surface it loudly instead.
        if (/^\s*-\s+\*\*D-/.test(line)) {
            // A malformed D-bullet still starts a (failed) new decision, so it ends the
            // previous one β€” flush before warning so a following continuation line cannot
            // be mis-appended to the prior valid decision.
            flush();
            console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
            continue;
        }
        // Continuation line for current decision (indented with space OR tab,
        // non-bullet, non-empty) β€” tab indentation must work too (review F12).
        if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) {
            current.text += ' ' + trimmed;
            continue;
        }
        // Blank line or unrelated content terminates the current decision
        if (trimmed === '') {
            flush();
        }
    }
    flush();
    return out;
}