@sabaiway/agent-workflow-memory 3.2.0 → 4.0.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/CHANGELOG.md +53 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/archive-changelog.mjs +300 -192
- package/references/scripts/archive-changelog.test.mjs +341 -0
- package/references/scripts/archive-conservation.test.mjs +466 -0
- package/references/scripts/archive-decisions.mjs +34 -17
- package/references/scripts/archive-decisions.test.mjs +93 -0
- package/references/scripts/archive-issues.mjs +344 -108
- package/references/scripts/archive-issues.test.mjs +762 -32
- package/references/scripts/archiver-structure.test.mjs +39 -0
- package/references/scripts/markdown-blocks.mjs +143 -0
- package/references/scripts/markdown-blocks.test.mjs +310 -0
- package/references/templates/changelog.md +3 -1
- package/references/templates/known_issues.md +13 -5
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
// Structural pin (green by construction, a tripwire not a proof): all three archivers read
|
|
8
|
+
// documents through the ONE shared tokenizer. A fourth archiver — or a future edit that reaches
|
|
9
|
+
// for a quick raw-line regex — reds here instead of quietly reintroducing the class the tokenizer
|
|
10
|
+
// retired: private frontmatter regexes, per-raw-line heading scans, fence blindness. Modeled on
|
|
11
|
+
// the release-scan / doc-parity precedent of asserting over the sources themselves.
|
|
12
|
+
|
|
13
|
+
const DIR = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const ARCHIVERS = ['archive-changelog.mjs', 'archive-issues.mjs', 'archive-decisions.mjs'];
|
|
15
|
+
|
|
16
|
+
describe('every archiver reads through the tokenizer — no raw scan survives', () => {
|
|
17
|
+
for (const name of ARCHIVERS) {
|
|
18
|
+
const source = readFileSync(resolve(DIR, name), 'utf8');
|
|
19
|
+
|
|
20
|
+
it(`${name} imports and calls tokenizeMarkdown`, () => {
|
|
21
|
+
assert.match(source, /import \{[^}]*tokenizeMarkdown[^}]*\} from '\.\/markdown-blocks\.mjs'/);
|
|
22
|
+
assert.match(source, /tokenizeMarkdown\(/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it(`${name} carries no private frontmatter regex`, () => {
|
|
26
|
+
// The CRLF-fragile `/^(---\n[\s\S]*?\n---\n)/` was triplicated beside each archiver; the
|
|
27
|
+
// tokenizer is now its only home.
|
|
28
|
+
assert.doesNotMatch(source, /---\\n\[\\s\\S\]/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it(`${name} never scans raw split lines for headings`, () => {
|
|
32
|
+
// The historical form: iterate `text.split('\n')` and regex-test each raw line. Both loop
|
|
33
|
+
// shapes that carried it are banned; heading recognition belongs to the tokenizer, unit
|
|
34
|
+
// grammars apply to `heading.text` tokens only.
|
|
35
|
+
assert.doesNotMatch(source, /\.forEach\(\(line/);
|
|
36
|
+
assert.doesNotMatch(source, /for \(const line of lines\)/);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The ONE block model the archivers read through.
|
|
3
|
+
//
|
|
4
|
+
// Every archiver used to scan `text.split('\n')` itself and test regexes against raw lines. That is
|
|
5
|
+
// one defect with many faces: a heading inside a fenced sample counts as real content, an unclosed
|
|
6
|
+
// fence hides the rest of the file, CRLF and trailing spaces break strict matches, and each archiver
|
|
7
|
+
// is wrong in its own way because each was fixed against only the inputs its author imagined. This
|
|
8
|
+
// module makes that one place instead of three.
|
|
9
|
+
//
|
|
10
|
+
// It is deliberately SMALL. It models exactly what the archivers must not get wrong:
|
|
11
|
+
// - frontmatter as the leading block, CRLF-safe
|
|
12
|
+
// - fenced regions (``` / ~~~), with an unclosed fence at EOF a LOUD error
|
|
13
|
+
// - ATX headings, emitted only OUTSIDE fences, matched on a trailing-whitespace-free view
|
|
14
|
+
// - which lines are fenced, so a caller can find a paragraph break without bisecting a fence
|
|
15
|
+
//
|
|
16
|
+
// It deliberately does NOT model: inline syntax, setext headings, indented code blocks, lists,
|
|
17
|
+
// blockquotes, HTML, tables, or nesting. Anything ambiguous REFUSES rather than guesses — fail-closed
|
|
18
|
+
// is the product, so "refuse" is a correct answer everywhere "parse" is hard.
|
|
19
|
+
//
|
|
20
|
+
// Line content is returned BYTE-EXACT. Normalisation applies only to the view used for matching, so
|
|
21
|
+
// re-emitting a block reproduces the file. Dependency-free, Node >= 22. No side effects on import.
|
|
22
|
+
|
|
23
|
+
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
24
|
+
|
|
25
|
+
const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/;
|
|
26
|
+
// Up to three leading spaces, then a run of at least three backticks or tildes (CommonMark).
|
|
27
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
28
|
+
// Indented 1-3 spaces is STILL a heading in CommonMark. Emitting it matters: a consumer's unit
|
|
29
|
+
// grammar is anchored at column 0, so the token exists, fails that grammar, and is refused — where
|
|
30
|
+
// dropping it here would silently glue it into the block above. The separator is spaces OR tabs
|
|
31
|
+
// (CommonMark): a tab-separated heading must become a token for the same reason.
|
|
32
|
+
const ATX_HEADING_RE = /^ {0,3}(#{1,6})[ \t]+(.*)$/;
|
|
33
|
+
const BACKTICK = '`';
|
|
34
|
+
|
|
35
|
+
// The matching view of a line: trailing whitespace and any CR are invisible in rendered markdown, so
|
|
36
|
+
// they must not decide whether something is a heading. A file written on Windows parses identically.
|
|
37
|
+
const toMatchable = (line) => line.replace(/\s+$/, '');
|
|
38
|
+
|
|
39
|
+
// A fence closes only on a bare run of the SAME marker, at least as long as the opener. An info
|
|
40
|
+
// string ("```markdown") opens; a trailing info string never closes.
|
|
41
|
+
const closesFence = (matchable, open) => {
|
|
42
|
+
const match = FENCE_RE.exec(matchable);
|
|
43
|
+
if (!match) return false;
|
|
44
|
+
const [, marker, info] = match;
|
|
45
|
+
return marker[0] === open.char && marker.length >= open.length && info.trim() === '';
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// A BACKTICK fence may not carry a backtick in its info string — otherwise a line of inline code
|
|
49
|
+
// (```a``` used inline) would open a fence and swallow every heading after it. Tilde fences have no
|
|
50
|
+
// such restriction, which is exactly why CommonMark distinguishes them.
|
|
51
|
+
const opensFence = (matchable) => {
|
|
52
|
+
const match = FENCE_RE.exec(matchable);
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
const [, marker, info] = match;
|
|
55
|
+
if (marker[0] === BACKTICK && info.includes(BACKTICK)) return null;
|
|
56
|
+
return { char: marker[0], length: marker.length };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Split a document into { frontmatter, lines, headings, fencedLines }.
|
|
60
|
+
//
|
|
61
|
+
// frontmatter the leading `---` block, byte-exact and possibly ''
|
|
62
|
+
// frontLines how many lines it occupies (so a caller can report 1-based file lines)
|
|
63
|
+
// lines the body, byte-exact, exactly as `split('\n')` would give
|
|
64
|
+
// headings [{ index, level, text, raw }] for every ATX heading OUTSIDE a fence
|
|
65
|
+
// fencedLines Set of body-line indexes inside a fence, fence markers included
|
|
66
|
+
//
|
|
67
|
+
// `label` names the source in refusals; pass the repo-relative path.
|
|
68
|
+
export const tokenizeMarkdown = (text, label = 'document') => {
|
|
69
|
+
const frontMatch = FRONTMATTER_RE.exec(text);
|
|
70
|
+
// A leading `---` is ambiguous: real frontmatter, or a thematic break whose next `---` is an entry
|
|
71
|
+
// separator — in which case everything between them would be swallowed and its headings hidden.
|
|
72
|
+
// REFUSE rather than pick, because both guesses are silently wrong in opposite directions: demoting
|
|
73
|
+
// real frontmatter (a YAML comment starts with `#`) leaves it in the body to be re-emitted twice,
|
|
74
|
+
// and promoting a thematic break hides content. The caller fixes it by making the file unambiguous.
|
|
75
|
+
if (frontMatch) {
|
|
76
|
+
const suspectAt = frontMatch[1]
|
|
77
|
+
.split('\n')
|
|
78
|
+
.findIndex((line) => ATX_HEADING_RE.test(toMatchable(line)) || FENCE_RE.test(toMatchable(line)));
|
|
79
|
+
if (suspectAt !== -1) {
|
|
80
|
+
throw fail(
|
|
81
|
+
1,
|
|
82
|
+
`${label}:${suspectAt + 1}: the leading \`---\` block contains "${frontMatch[1].split('\n')[suspectAt]}", ` +
|
|
83
|
+
'so it is either frontmatter holding a heading or fence, or a thematic break whose closing `---` ' +
|
|
84
|
+
'belongs to the body — and the two are indistinguishable here. Separate them: keep frontmatter ' +
|
|
85
|
+
'free of `#` and fence lines, or put a blank line and prose before the first `---`.',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const frontmatter = frontMatch ? frontMatch[1] : '';
|
|
90
|
+
const frontLines = frontmatter === '' ? 0 : frontmatter.split('\n').length - 1;
|
|
91
|
+
const lines = text.slice(frontmatter.length).split('\n');
|
|
92
|
+
|
|
93
|
+
const headings = [];
|
|
94
|
+
const fencedLines = new Set();
|
|
95
|
+
let open = null;
|
|
96
|
+
|
|
97
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
98
|
+
const matchable = toMatchable(lines[index]);
|
|
99
|
+
|
|
100
|
+
if (open) {
|
|
101
|
+
fencedLines.add(index);
|
|
102
|
+
if (closesFence(matchable, open)) open = null;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fence = opensFence(matchable);
|
|
107
|
+
if (fence) {
|
|
108
|
+
open = { ...fence, index };
|
|
109
|
+
fencedLines.add(index);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const heading = ATX_HEADING_RE.exec(matchable);
|
|
114
|
+
if (heading) {
|
|
115
|
+
headings.push({ index, level: heading[1].length, text: matchable, raw: lines[index] });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (open) {
|
|
120
|
+
// Left open, the remainder of the file silently stops being scanned: every later heading
|
|
121
|
+
// disappears and its text ends up inside whichever block precedes it, where a compressor that
|
|
122
|
+
// keeps only an opening paragraph will drop it — a loss that lands long after a green check.
|
|
123
|
+
throw fail(
|
|
124
|
+
1,
|
|
125
|
+
`${label}:${frontLines + open.index + 1}: "${lines[open.index]}" opens a code fence that is ` +
|
|
126
|
+
'never closed, so every heading after it is invisible and its text is silently absorbed. ' +
|
|
127
|
+
`Close it with a bare \`${open.char.repeat(open.length)}\`, then re-run.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { frontmatter, frontLines, lines, headings, fencedLines };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// The index of the first blank line at or after `from` that is NOT inside a fence, or -1. Callers
|
|
135
|
+
// that split a block into "opening paragraph" and "rest" must use this rather than a bare blank-line
|
|
136
|
+
// scan: a fenced sample contains blank lines, and cutting there writes a half-fence into an archive
|
|
137
|
+
// that the next run cannot read.
|
|
138
|
+
export const findParagraphBreak = (lines, fencedLines, from = 0) => {
|
|
139
|
+
for (let index = from; index < lines.length; index += 1) {
|
|
140
|
+
if (lines[index].trim() === '' && !fencedLines.has(index)) return index;
|
|
141
|
+
}
|
|
142
|
+
return -1;
|
|
143
|
+
};
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { tokenizeMarkdown, findParagraphBreak } from './markdown-blocks.mjs';
|
|
4
|
+
|
|
5
|
+
const FM = '---\ntype: history\nlastUpdated: 2026-07-28\nmaxLines: 700\n---\n';
|
|
6
|
+
const B = '```';
|
|
7
|
+
const T = '~~~';
|
|
8
|
+
|
|
9
|
+
describe('frontmatter', () => {
|
|
10
|
+
it('splits the leading block and counts its lines, leaving the body byte-exact', () => {
|
|
11
|
+
const { frontmatter, frontLines, lines } = tokenizeMarkdown(`${FM}\n# Doc\n\n## 2026-07-20 — a\n`);
|
|
12
|
+
assert.equal(frontmatter, FM);
|
|
13
|
+
assert.equal(frontLines, 5);
|
|
14
|
+
assert.equal(lines[1], '# Doc');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('recognises CRLF frontmatter', () => {
|
|
18
|
+
const { frontmatter, frontLines } = tokenizeMarkdown(`${FM}\n# Doc\n`.replace(/\n/g, '\r\n'));
|
|
19
|
+
assert.match(frontmatter, /^---\r\n/);
|
|
20
|
+
assert.equal(frontLines, 5);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('tolerates a document with no frontmatter at all', () => {
|
|
24
|
+
const { frontmatter, frontLines, headings } = tokenizeMarkdown('# Doc\n\n## 2026-07-20 — a\n');
|
|
25
|
+
assert.equal(frontmatter, '');
|
|
26
|
+
assert.equal(frontLines, 0);
|
|
27
|
+
assert.equal(headings.length, 2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('REFUSES a leading `---` block that is indistinguishable from a thematic break', () => {
|
|
31
|
+
assert.throws(
|
|
32
|
+
() => tokenizeMarkdown('---\n\n# Doc\n\n## 2026-07-20 — a\n\n---\n\n## 2026-07-19 — b\n', 'docs/ai/changelog.md'),
|
|
33
|
+
(err) => {
|
|
34
|
+
assert.equal(err.exitCode, 1);
|
|
35
|
+
assert.match(err.message, /docs\/ai\/changelog\.md:3:/);
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('REFUSES rather than silently demoting frontmatter that holds a YAML comment', () => {
|
|
42
|
+
// Demoting it would leave the real frontmatter in the body, so a rebuild writes a fresh one and
|
|
43
|
+
// the original is duplicated. Guessing the other way hides content. Both are silent; refuse.
|
|
44
|
+
assert.throws(
|
|
45
|
+
() => tokenizeMarkdown('---\n# pinned by AD-084\ntype: history\n---\n\n## 2026-07-20 — a\n', 'f.md'),
|
|
46
|
+
/f\.md:2:/,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('headings', () => {
|
|
52
|
+
it('reports level, index and a trailing-whitespace-free text, keeping raw byte-exact', () => {
|
|
53
|
+
const { headings } = tokenizeMarkdown(`${FM}\n## 2026-07-20 — a \n\n### deeper\n`);
|
|
54
|
+
assert.deepEqual(headings.map((h) => h.level), [2, 3]);
|
|
55
|
+
assert.equal(headings[0].text, '## 2026-07-20 — a');
|
|
56
|
+
assert.equal(headings[0].raw, '## 2026-07-20 — a ');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('sees a heading that carries a CR, so a Windows file parses identically', () => {
|
|
60
|
+
const { headings } = tokenizeMarkdown(`${FM}\n## 2026-07-20\n`.replace(/\n/g, '\r\n'));
|
|
61
|
+
assert.equal(headings.length, 1);
|
|
62
|
+
assert.equal(headings[0].text, '## 2026-07-20');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('emits an indented heading as a TOKEN so a column-0 grammar can refuse it loudly', () => {
|
|
66
|
+
const { headings } = tokenizeMarkdown(`${FM}\n## 2026-07-20 — a\n\n ## 2026-07-19 — indented\n`);
|
|
67
|
+
assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — a', ' ## 2026-07-19 — indented']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not treat a bare hash run without a space as a heading', () => {
|
|
71
|
+
const { headings } = tokenizeMarkdown(`${FM}\n##notaheading\n`);
|
|
72
|
+
assert.equal(headings.length, 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('a tab after the hashes still makes a heading token (CommonMark allows spaces or tabs)', () => {
|
|
76
|
+
const { headings } = tokenizeMarkdown(`${FM}\n##\t2026-07-18 — tab separated\n`);
|
|
77
|
+
assert.deepEqual(headings.map((h) => h.level), [2]);
|
|
78
|
+
assert.equal(headings[0].text, '##\t2026-07-18 — tab separated');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('fences', () => {
|
|
83
|
+
it('hides headings inside a backtick fence', () => {
|
|
84
|
+
const { headings, fencedLines } = tokenizeMarkdown(
|
|
85
|
+
`${FM}\n## 2026-07-20 — real\n\n${B}markdown\n## 2026-07-19 — a sample\n${B}\n\nend.\n`,
|
|
86
|
+
);
|
|
87
|
+
assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
|
|
88
|
+
assert.equal(fencedLines.has(3), true);
|
|
89
|
+
assert.equal(fencedLines.has(5), true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('a backtick run whose info string contains a backtick is inline code, not a fence', () => {
|
|
93
|
+
const { headings } = tokenizeMarkdown(
|
|
94
|
+
`${FM}\n${B}a${B} used inline\n\n## 2026-07-20 — a real entry\n\nbody.\n\n${B}\nsample\n${B}\n`,
|
|
95
|
+
);
|
|
96
|
+
assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — a real entry']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('a tilde fence MAY carry backticks in its info string', () => {
|
|
100
|
+
const { headings } = tokenizeMarkdown(`${FM}\n${T}a${B}\n## 2026-07-19 — fenced\n${T}\n\n## 2026-07-20 — real\n`);
|
|
101
|
+
assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('closes only on the same marker, so a backtick run inside a tilde fence stays content', () => {
|
|
105
|
+
const { headings } = tokenizeMarkdown(
|
|
106
|
+
`${FM}\n## 2026-07-20 — real\n\n${T}markdown\n${B}\n## 2026-07-19 — nested sample\n${B}\n${T}\n\nend.\n`,
|
|
107
|
+
);
|
|
108
|
+
assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('needs a closer at least as long as the opener', () => {
|
|
112
|
+
const { headings } = tokenizeMarkdown(
|
|
113
|
+
`${FM}\n## 2026-07-20 — real\n\n\`\`\`\`\n${B}\n## 2026-07-19 — still fenced\n\`\`\`\`\n\nend.\n`,
|
|
114
|
+
);
|
|
115
|
+
assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does not let an info string close a fence', () => {
|
|
119
|
+
const { headings } = tokenizeMarkdown(
|
|
120
|
+
`${FM}\n## 2026-07-20 — real\n\n${B}markdown\n${B}js\n## 2026-07-19 — still fenced\n${B}\n\nend.\n`,
|
|
121
|
+
);
|
|
122
|
+
assert.deepEqual(headings.map((h) => h.text), ['## 2026-07-20 — real']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('REFUSES an unclosed fence, naming the 1-based file line that opened it', () => {
|
|
126
|
+
let caught;
|
|
127
|
+
try {
|
|
128
|
+
tokenizeMarkdown(`${FM}\n## 2026-07-20 — real\n\n${B}markdown\n## 2026-07-19 — hidden\n`, 'docs/ai/changelog.md');
|
|
129
|
+
} catch (err) {
|
|
130
|
+
caught = err;
|
|
131
|
+
}
|
|
132
|
+
assert.ok(caught, 'expected a refusal');
|
|
133
|
+
assert.equal(caught.exitCode, 1);
|
|
134
|
+
assert.match(caught.message, /docs\/ai\/changelog\.md:9:/);
|
|
135
|
+
assert.match(caught.message, /never closed/);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('findParagraphBreak', () => {
|
|
140
|
+
it('skips blank lines that live inside a fence', () => {
|
|
141
|
+
const { lines, fencedLines } = tokenizeMarkdown(`${FM}\n## 2026-07-20 — a\n${B}\nx\n\ny\n${B}\n\nafter.\n`);
|
|
142
|
+
assert.equal(fencedLines.has(4), true);
|
|
143
|
+
assert.equal(findParagraphBreak(lines, fencedLines, 1), 7);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('returns -1 when no unfenced blank line follows', () => {
|
|
147
|
+
const { lines, fencedLines } = tokenizeMarkdown(`${FM}\n## 2026-07-20 — a\nbody.\n`);
|
|
148
|
+
assert.equal(findParagraphBreak(lines, fencedLines, 4), -1);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── properties over generated documents ───────────────────────────────────────────────
|
|
153
|
+
//
|
|
154
|
+
// The generator builds each document AND its ground truth in the SAME pass, by construction: when
|
|
155
|
+
// it emits a fenced region it records those line indexes directly, never by re-deriving them with a
|
|
156
|
+
// regex. So the oracle is independent of the tokenizer's logic, and a disagreement is a real
|
|
157
|
+
// failure rather than two copies of the same mistake agreeing.
|
|
158
|
+
//
|
|
159
|
+
// It deliberately emits the shapes that shipped as bugs: unclosed fences mid-document, closers that
|
|
160
|
+
// are too short, closers carrying an info string, the other marker inside a fence, backtick inline
|
|
161
|
+
// code at column 0, indented headings, trailing whitespace, and both line endings. Seeded, so a
|
|
162
|
+
// failure replays from its seed alone.
|
|
163
|
+
|
|
164
|
+
const buildDocument = (seed) => {
|
|
165
|
+
let state = seed;
|
|
166
|
+
// Drawn from the HIGH bits: this LCG's low bit strictly alternates, so `state % 2` is fixed by
|
|
167
|
+
// call parity and every other two-apart draw correlates. With the low bits, a backtick fence
|
|
168
|
+
// carrying an info string was unreachable in every seed — a generator blind spot that looks like
|
|
169
|
+
// coverage.
|
|
170
|
+
const rand = (n) => {
|
|
171
|
+
state = (state * 1103515245 + 12345) % 2147483648;
|
|
172
|
+
return Math.floor(state / 65536) % n;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const eol = rand(2) === 0 ? '\n' : '\r\n';
|
|
176
|
+
const rows = [];
|
|
177
|
+
const fenced = new Set();
|
|
178
|
+
const headings = [];
|
|
179
|
+
let unclosedAt = null;
|
|
180
|
+
|
|
181
|
+
const pushHeading = () => {
|
|
182
|
+
const indent = ' '.repeat(rand(4));
|
|
183
|
+
const hashes = '#'.repeat(2 + rand(2));
|
|
184
|
+
const body = rand(2) === 0 ? `2026-07-2${rand(9)} — entry` : `2026-7-${rand(9)} — malformed`;
|
|
185
|
+
const text = `${indent}${hashes} ${body}`;
|
|
186
|
+
headings.push({ index: rows.length, text });
|
|
187
|
+
rows.push(text + ' '.repeat(rand(3)));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const pushFence = (allowUnclosed) => {
|
|
191
|
+
const char = rand(2) === 0 ? '`' : '~';
|
|
192
|
+
const length = 3 + rand(3);
|
|
193
|
+
const marker = char.repeat(length);
|
|
194
|
+
const info = rand(2) === 0 ? '' : 'markdown';
|
|
195
|
+
const openIndex = rows.length;
|
|
196
|
+
fenced.add(openIndex);
|
|
197
|
+
rows.push(`${marker}${info}` + ' '.repeat(rand(3)));
|
|
198
|
+
|
|
199
|
+
const inner = [
|
|
200
|
+
`## 2026-07-1${rand(9)} — fenced sample`,
|
|
201
|
+
'',
|
|
202
|
+
char.repeat(length - 1), // too short to close
|
|
203
|
+
`${marker}js`, // info string never closes
|
|
204
|
+
(char === '`' ? '~' : '`').repeat(length + 1), // the other marker never closes
|
|
205
|
+
'plain fenced text',
|
|
206
|
+
];
|
|
207
|
+
const innerCount = 1 + rand(inner.length);
|
|
208
|
+
for (let i = 0; i < innerCount; i += 1) {
|
|
209
|
+
fenced.add(rows.length);
|
|
210
|
+
rows.push(inner[(i + rand(inner.length)) % inner.length]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (allowUnclosed && rand(4) === 0) {
|
|
214
|
+
unclosedAt = openIndex;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
fenced.add(rows.length);
|
|
218
|
+
rows.push(char.repeat(length + rand(2)) + ' '.repeat(rand(3)));
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const frontmatter = rand(2) === 0 ? FM.replace(/\n/g, eol) : '';
|
|
222
|
+
const segmentCount = 3 + rand(5);
|
|
223
|
+
// An unclosed fence may open ANYWHERE, not only last: the generator keeps emitting after it and
|
|
224
|
+
// marks the remainder fenced, so the refusal is exercised with real content behind it rather than
|
|
225
|
+
// at a convenient end-of-file.
|
|
226
|
+
for (let i = 0; i < segmentCount; i += 1) {
|
|
227
|
+
if (unclosedAt !== null) {
|
|
228
|
+
fenced.add(rows.length);
|
|
229
|
+
rows.push(rand(2) === 0 ? `## 2026-07-0${i} — hidden behind the open fence` : `trailing text ${i}`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const kind = rand(5);
|
|
233
|
+
if (kind === 0) pushHeading();
|
|
234
|
+
else if (kind === 1) rows.push('');
|
|
235
|
+
else if (kind === 2) rows.push(`${B}inline${B} used in prose`);
|
|
236
|
+
else if (kind === 3) pushFence(true);
|
|
237
|
+
else rows.push(`body text ${i}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { text: frontmatter + rows.join(eol) + eol, frontmatter, fenced, headings, unclosedAt };
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
describe('properties over generated documents', () => {
|
|
244
|
+
const SEEDS = 400;
|
|
245
|
+
|
|
246
|
+
it('the fenced-line set equals the oracle the generator recorded', () => {
|
|
247
|
+
for (let seed = 1; seed <= SEEDS; seed += 1) {
|
|
248
|
+
const doc = buildDocument(seed);
|
|
249
|
+
if (doc.unclosedAt !== null) continue;
|
|
250
|
+
const { fencedLines } = tokenizeMarkdown(doc.text, `seed-${seed}`);
|
|
251
|
+
assert.deepEqual([...fencedLines].sort((a, b) => a - b), [...doc.fenced].sort((a, b) => a - b), `seed ${seed}`);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('the heading tokens equal the oracle — index and text', () => {
|
|
256
|
+
for (let seed = 1; seed <= SEEDS; seed += 1) {
|
|
257
|
+
const doc = buildDocument(seed);
|
|
258
|
+
if (doc.unclosedAt !== null) continue;
|
|
259
|
+
const { headings } = tokenizeMarkdown(doc.text, `seed-${seed}`);
|
|
260
|
+
assert.deepEqual(
|
|
261
|
+
headings.map((h) => ({ index: h.index, text: h.text })),
|
|
262
|
+
doc.headings,
|
|
263
|
+
`seed ${seed}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('an unclosed fence always refuses, naming the line the generator opened it on', () => {
|
|
269
|
+
let covered = 0;
|
|
270
|
+
for (let seed = 1; seed <= SEEDS; seed += 1) {
|
|
271
|
+
const doc = buildDocument(seed);
|
|
272
|
+
if (doc.unclosedAt === null) continue;
|
|
273
|
+
covered += 1;
|
|
274
|
+
const frontLines = doc.frontmatter === '' ? 0 : doc.frontmatter.split('\n').length - 1;
|
|
275
|
+
assert.throws(
|
|
276
|
+
() => tokenizeMarkdown(doc.text, `seed-${seed}`),
|
|
277
|
+
(err) => {
|
|
278
|
+
assert.equal(err.exitCode, 1, `seed ${seed}`);
|
|
279
|
+
assert.ok(
|
|
280
|
+
err.message.startsWith(`seed-${seed}:${frontLines + doc.unclosedAt + 1}:`),
|
|
281
|
+
`seed ${seed}: refusal names the wrong line — ${err.message.slice(0, 60)}`,
|
|
282
|
+
);
|
|
283
|
+
return true;
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
assert.ok(covered >= 10, `the generator must actually produce unclosed fences (got ${covered})`);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('line endings never change the block structure', () => {
|
|
291
|
+
for (let seed = 1; seed <= SEEDS; seed += 1) {
|
|
292
|
+
const doc = buildDocument(seed);
|
|
293
|
+
if (doc.unclosedAt !== null) continue;
|
|
294
|
+
const lf = doc.text.replace(/\r\n/g, '\n');
|
|
295
|
+
const a = tokenizeMarkdown(lf, `seed-${seed}`);
|
|
296
|
+
const b = tokenizeMarkdown(lf.replace(/\n/g, '\r\n'), `seed-${seed}`);
|
|
297
|
+
assert.deepEqual(a.headings.map((h) => h.text), b.headings.map((h) => h.text), `seed ${seed}`);
|
|
298
|
+
assert.deepEqual([...a.fencedLines], [...b.fencedLines], `seed ${seed}`);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('the body is returned byte-exact — rejoining reproduces the input', () => {
|
|
303
|
+
for (let seed = 1; seed <= SEEDS; seed += 1) {
|
|
304
|
+
const doc = buildDocument(seed);
|
|
305
|
+
if (doc.unclosedAt !== null) continue;
|
|
306
|
+
const { frontmatter, lines } = tokenizeMarkdown(doc.text, `seed-${seed}`);
|
|
307
|
+
assert.equal(frontmatter + lines.join('\n'), doc.text, `seed ${seed}`);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -10,7 +10,9 @@ maxLines: 700
|
|
|
10
10
|
# AI Session Changelog
|
|
11
11
|
|
|
12
12
|
> One entry per session. Newest at the top. Entries roll off to `history/recent.md` (WARM) then `history/YYYY-MM.md` (COLD) via the archive script.
|
|
13
|
-
> Heading format is load-bearing for rotation: `## YYYY
|
|
13
|
+
> Heading format is load-bearing for rotation: `## YYYY-MM-DD — <title>`. The rotator also still
|
|
14
|
+
> reads the legacy dotted form `## YYYY.MM.DD`, so existing archives keep working — but write ISO,
|
|
15
|
+
> the form every other date in this substrate uses, including `lastUpdated:` above.
|
|
14
16
|
|
|
15
17
|
## {{DATE}} — Bootstrap
|
|
16
18
|
|
|
@@ -11,6 +11,19 @@ maxLines: 300
|
|
|
11
11
|
|
|
12
12
|
> Every bug we hit. Status, workaround, impact, plan. Avoids re-discovering pain.
|
|
13
13
|
|
|
14
|
+
When an issue is resolved, move its section under `## 🟢 Resolved` and REPLACE its
|
|
15
|
+
`- **Status:**` line with a line-leading ISO-dated `**Resolved:**` field — that one dated line is
|
|
16
|
+
what the archive script reads (the legacy `**Status:** ✅ FIXED (YYYY-MM-DD)` form is still read;
|
|
17
|
+
~~strikethrough~~ on the heading is optional decoration). Keeping an open `Status:` line next to a
|
|
18
|
+
dated `Resolved:` line refuses loudly. Write it like this:
|
|
19
|
+
|
|
20
|
+
```markdown
|
|
21
|
+
### ~~Issue-042 — Example resolved issue~~
|
|
22
|
+
- **Resolved:** 2026-01-15 — what fixed it ([[AD-NNN]])
|
|
23
|
+
- **Resolution:** the fix, one line
|
|
24
|
+
- **Commit:** abc1234
|
|
25
|
+
```
|
|
26
|
+
|
|
14
27
|
## 🔴 Open
|
|
15
28
|
|
|
16
29
|
### Issue-001 — {{Title}}
|
|
@@ -23,11 +36,6 @@ maxLines: 300
|
|
|
23
36
|
|
|
24
37
|
## 🟢 Resolved
|
|
25
38
|
|
|
26
|
-
### Issue-XXX — {{Title}}
|
|
27
|
-
- **Resolved:** {{DATE}}
|
|
28
|
-
- **Resolution:** {{what fixed it}}
|
|
29
|
-
- **Commit:** {{SHA}}
|
|
30
|
-
|
|
31
39
|
---
|
|
32
40
|
|
|
33
41
|
> Resolved issues older than the window are rotated to `history/issues-resolved.md` by the issue-archive script.
|