diff --git a/eslint-rules/no-adhoc-markdown-parsing.cjs b/eslint-rules/no-adhoc-markdown-parsing.cjs new file mode 100644 index 000000000..309ce6dc7 --- /dev/null +++ b/eslint-rules/no-adhoc-markdown-parsing.cjs @@ -0,0 +1,131 @@ +'use strict'; + +/** + * no-adhoc-markdown-parsing + * + * Flags hand-rolled markdown-structure scanning in src/*.cts that duplicates + * the canonical seam (src/markdown-sectionizer.cts). Applies to two patterns: + * + * 1. FENCE-BLOCK-STRIP — regex literals whose source contains a triple-backtick + * or triple-tilde fence delimiter AND a multiline body + * ([\s\S] or [\S\s]), indicating the regex strips/matches + * a fenced CODE BLOCK spanning multiple lines. + * + * A bare single-line fence-opener test like /^```/ or + * /^\s*(?:```|~~~)/ is NOT flagged — that is line + * detection / normalisation, not block-stripping. + * + * 2. SECTION-COLLECT — regex literals of the shape + * /(#{...}\n)([\s\S]*?)(?=\n#{...}|$)/ (a heading + * capture followed by a non-greedy body up to a heading + * lookahead). These hand-roll what collectSection() owns. + * Fingerprint: [\\s\\S] (multiline body) AND (?= lookahead + * that references a heading anchor #. + * + * Per-finding exemption: add // allow-adhoc-markdown: as a + * trailing comment on the same source line, OR as a standalone comment on the + * line immediately preceding the flagged node. (Mirrors no-source-grep's + * // allow-test-rule: mechanism but is scoped to individual findings.) + * + * Authors must import from src/markdown-sectionizer.cts instead. + */ + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow hand-rolled markdown-structure scanning (fence-block-strip, section-collect) in src/*.cts — import the markdown-sectionizer seam instead.', + category: 'Best Practices', + }, + schema: [], + messages: { + fenceRegex: + 'Ad-hoc fence-block-strip regex detected (triple-fence delimiter + multiline body). Import stripFencedCode() from ./markdown-sectionizer instead. Suppress with: // allow-adhoc-markdown: ', + sectionCollect: + 'Ad-hoc section-collect regex detected (heading + [\\s\\S]*? + lookahead). Import collectSection() from ./markdown-sectionizer instead. Suppress with: // allow-adhoc-markdown: ', + }, + }, + + create(context) { + // Only run on src/*.cts files + const filename = context.getFilename ? context.getFilename() : context.filename; + if (!/(?:^|\/)src\/[^/]+\.cts$/.test(filename.replace(/\\/g, '/'))) { + return {}; + } + + const sourceCode = context.getSourceCode ? context.getSourceCode() : context.sourceCode; + + /** + * Check whether a node has a trailing // allow-adhoc-markdown: + * comment on the same source line, OR a standalone allow comment on the + * line immediately before the node's start line. + */ + function isAllowed(node) { + const nodeStartLine = node.loc.start.line; + + const allComments = sourceCode.getAllComments(); + return allComments.some((c) => { + if (!/allow-adhoc-markdown:\s*\S/.test(c.value)) return false; + // Same line, or one line above + return c.loc.start.line === nodeStartLine || c.loc.start.line === nodeStartLine - 1; + }); + } + + // ── Fence-block-strip detection ────────────────────────────────────────── + // A regex literal whose source contains ``` or ~~~ AND contains [\s\S] or + // [\S\s] (a multiline body), indicating it strips/matches a fenced block. + // A bare /^```/ or /^\s*(?:```|~~~)/ (line-detection, no multiline body) + // is explicitly NOT flagged. + const TRIPLE_BACKTICK = '```'; // ``` + const TRIPLE_TILDE = '~~~'; + + function isFenceBlockStripRegex(node) { + if (node.type !== 'Literal' || !node.regex) return false; + const src = node.regex.pattern || ''; + // Must contain a triple fence delimiter + if (!src.includes(TRIPLE_BACKTICK) && !src.includes(TRIPLE_TILDE)) return false; + // Must ALSO contain a multiline body marker — i.e. it spans blocks, not just lines + const hasMultilineBody = src.includes('[\\s\\S]') || src.includes('[\\S\\s]'); + return hasMultilineBody; + } + + // ── Section-collect regex detection ───────────────────────────────────── + // Matches patterns of the shape: + // /(#{1,6}...\n)([\s\S]*?)(?=\n#{...}|$)/ + // The key fingerprint is: [\\s\\S] (or [\s\S]) AND (?= (lookahead) AND # in + // the same regex, forming the "body up to next heading" construct. + function isSectionCollectRegex(node) { + if (node.type !== 'Literal' || !node.regex) return false; + const src = node.regex.pattern || ''; + // Must contain [\s\S] (the non-greedy body) + const hasMultilineBody = src.includes('[\\s\\S]') || src.includes('[\\S\\s]'); + if (!hasMultilineBody) return false; + // Must contain a lookahead (?= that references a heading anchor # + const hasHeadingLookahead = /\(\?=.*#/.test(src); + return hasHeadingLookahead; + } + + return { + Literal(node) { + // 1. Fence-block-strip regex + if (isFenceBlockStripRegex(node)) { + if (!isAllowed(node)) { + context.report({ node, messageId: 'fenceRegex' }); + } + return; + } + + // 2. Section-collect regex + if (isSectionCollectRegex(node)) { + if (!isAllowed(node)) { + context.report({ node, messageId: 'sectionCollect' }); + } + } + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 957b594ac..49f3facba 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,7 @@ import noMagicSleepInTests from './eslint-rules/no-magic-sleep-in-tests.cjs'; import noElapsedAssertion from './eslint-rules/no-elapsed-assertion.cjs'; import noRawRmsyncInTests from './eslint-rules/no-raw-rmsync-in-tests.cjs'; import noTautologicalAssert from './eslint-rules/no-tautological-assert.cjs'; +import noAdhocMarkdownParsing from './eslint-rules/no-adhoc-markdown-parsing.cjs'; const localPlugin = { rules: { @@ -22,6 +23,7 @@ const localPlugin = { 'no-elapsed-assertion': noElapsedAssertion, 'no-raw-rmsync-in-tests': noRawRmsyncInTests, 'no-tautological-assert': noTautologicalAssert, + 'no-adhoc-markdown-parsing': noAdhocMarkdownParsing, }, }; @@ -171,6 +173,9 @@ export default tseslint.config( // these rules add lint-level coverage. warn-first per the harness convention. { files: ['src/**/*.cts'], + plugins: { + local: localPlugin, + }, extends: [tseslint.configs.recommendedTypeChecked], languageOptions: { parserOptions: { @@ -180,6 +185,9 @@ export default tseslint.config( }, rules: { '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + // ADR-1372 T7: enforce use of the markdown-sectionizer seam; grandfather + // pre-migration sites with // allow-adhoc-markdown: + 'local/no-adhoc-markdown-parsing': 'error', }, }, diff --git a/src/audit.cts b/src/audit.cts index 63370026f..41bf72a93 100644 --- a/src/audit.cts +++ b/src/audit.cts @@ -162,7 +162,7 @@ function scanDebugSessions(planDir: string): DebugSessionItem[] { // Extract hypothesis from "Current Focus" block if parseable let hypothesis = ''; - const focusMatch = content.match(/##\s*Current Focus[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i); + const focusMatch = content.match(/##\s*Current Focus[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i); // allow-adhoc-markdown: pre-seam read-only section extract in audit.cts; pending migration #1372 if (focusMatch) { const focusText = focusMatch[1].trim().split('\n')[0].trim(); hypothesis = sanitizeForDisplay(focusText.slice(0, 100)); @@ -649,7 +649,7 @@ function scanContextQuestions(planDir: string): ContextQuestionItem[] { // Also check for ## Open Questions section in body if (questions.length === 0) { - const oqMatch = content.match(/##\s*Open Questions[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i); + const oqMatch = content.match(/##\s*Open Questions[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i); // allow-adhoc-markdown: pre-seam read-only section extract in audit.cts; pending migration #1372 if (oqMatch) { const oqBody = oqMatch[1].trim(); if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) { diff --git a/src/milestone.cts b/src/milestone.cts index cf4bee903..e06dc6926 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -319,7 +319,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo // Reset Current Position narrative so resume/progress flows do not keep // pointing at closed-phase execution instructions. - const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; + const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify in milestone.cts; pending collectSection migration #1372 const closedPositionBody = `\nPhase: Milestone ${version} complete\n` + `Plan: —\n` + @@ -332,7 +332,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo } // Normalize operator-next-step tails that can become stale after close. - const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i; + const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify in milestone.cts; pending collectSection migration #1372 if (operatorPattern.test(stateContent)) { stateContent = stateContent.replace( operatorPattern, diff --git a/src/phase-lifecycle.cts b/src/phase-lifecycle.cts index f4fd79f24..93cc8997a 100644 --- a/src/phase-lifecycle.cts +++ b/src/phase-lifecycle.cts @@ -49,6 +49,7 @@ export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgre // Count total phase rows in the progress table. // Identify the table by looking for Phase|...|Status|...|Completed header. const progressTableMatch = roadmapContent.match( + // allow-adhoc-markdown: table-scoped regex with heading lookahead as stop; table parsing, out of seam scope; pending #1372 /\|\s*Phase\s*\|[^|]*\|[^|]*Status[^|]*\|[^|]*Completed[^|]*\|[\s\S]*?(?=\n\n|\n##|$)/i, ); if (progressTableMatch) { diff --git a/src/state.cts b/src/state.cts index fc856e377..448ef41fe 100644 --- a/src/state.cts +++ b/src/state.cts @@ -568,7 +568,7 @@ function cmdStateRecordMetric(cwd: string, options: StateRecordMetricOptions, ra let created = false; readModifyWriteStateMd(statePath, (content) => { // Find Performance Metrics section and its table - const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; + const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; // allow-adhoc-markdown: metrics-table write-path section-collect in state.cts; pending collectSection migration #1372 const metricsMatch = content.match(metricsPattern); const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`; @@ -1138,8 +1138,8 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions, * Returns the match whose group 1 is the section body, or null. */ function matchSessionSection(body: string): RegExpMatchArray | null { - return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i) - || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i); + return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i) // allow-adhoc-markdown: read-only session-section extract in state.cts; pending collectSection migration #1372 + || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i); // allow-adhoc-markdown: read-only session-continuity section extract in state.cts; pending collectSection migration #1372 } function parseProsePhaseField(value: string | null): { phase: string | null; name: string | null } { @@ -1218,7 +1218,7 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void { // Extract decisions table const decisions: Array<{ phase: string; summary: string; rationale: string }> = []; - const decisionsMatch = body.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); + const decisionsMatch = body.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); // allow-adhoc-markdown: read-only decisions-table section-collect in state.cts; pending collectSection migration #1372 if (decisionsMatch) { const tableBody = decisionsMatch[1]; const rows = tableBody.trim().split('\n').filter(r => r.includes('|')); @@ -1236,7 +1236,7 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void { // Extract blockers list const blockers: string[] = []; - const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); + const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); // allow-adhoc-markdown: read-only blockers section-collect in state.cts; pending collectSection migration #1372 if (blockersMatch) { const blockersSection = blockersMatch[1]; const items = blockersSection.match(/^-\s+(.+)$/gm) || []; diff --git a/tests/eslint-rules.test.cjs b/tests/eslint-rules.test.cjs index c0527862d..127bf8823 100644 --- a/tests/eslint-rules.test.cjs +++ b/tests/eslint-rules.test.cjs @@ -8,6 +8,7 @@ * - local/no-magic-sleep-in-tests * - local/no-elapsed-assertion * - local/no-raw-rmsync-in-tests + * - local/no-adhoc-markdown-parsing */ const { test, describe } = require('node:test'); @@ -19,6 +20,7 @@ const noMagicSleepInTests = require('../eslint-rules/no-magic-sleep-in-tests.cjs const noElapsedAssertion = require('../eslint-rules/no-elapsed-assertion.cjs'); const noRawRmsyncInTests = require('../eslint-rules/no-raw-rmsync-in-tests.cjs'); const noTautologicalAssert = require('../eslint-rules/no-tautological-assert.cjs'); +const noAdhocMarkdownParsing = require('../eslint-rules/no-adhoc-markdown-parsing.cjs'); const ruleTester = new RuleTester({ languageOptions: { @@ -812,3 +814,167 @@ describe('no-tautological-assert rule', () => { }); }); }); + +// ─── no-adhoc-markdown-parsing ─────────────────────────────────────────────── + +describe('no-adhoc-markdown-parsing rule', () => { + test('rule module exports a create function', () => { + assert.strictEqual(typeof noAdhocMarkdownParsing.create, 'function'); + }); + + // ── POSITIVE cases: flag fence-block-strip and section-collect ──────────── + + test('invalid: fence-block-strip regex with triple-backtick and multiline body', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + // /```[\s\S]*?```/ — triple-backtick + [\s\S] body → flagged as fenceRegex + code: String.raw`const stripFences = /` + '```' + String.raw`[\s\S]*?` + '```' + '/;', + filename: 'src/some-module.cts', + errors: [{ messageId: 'fenceRegex' }], + }, + ], + }); + }); + + test('invalid: fence-block-strip regex with triple-tilde and multiline body', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + // /~~~[\s\S]*?~~~/ — triple-tilde + [\s\S] body → flagged as fenceRegex + code: String.raw`const stripTildes = /~~~[\s\S]*?~~~/;`, + filename: 'src/some-module.cts', + errors: [{ messageId: 'fenceRegex' }], + }, + ], + }); + }); + + test('invalid: section-collect regex with heading capture, multiline body, heading lookahead', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + // /(##\s*X\n)([\s\S]*?)(?=\n##|$)/ — the classic section-collect fingerprint + code: String.raw`const pat = /(##\s*X\n)([\s\S]*?)(?=\n##|$)/;`, + filename: 'src/some-module.cts', + errors: [{ messageId: 'sectionCollect' }], + }, + ], + }); + }); + + // ── NEGATIVE cases: single-line fence tests and heading matches NOT flagged ─ + + test('valid: bare single-line fence-opener /^```/ is NOT flagged', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [ + { + code: 'const fenceRegex = /^' + '```' + '/;', + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + + test('valid: /^\\s*(?:```|~~~)/ fence-line test is NOT flagged', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [ + { + code: String.raw`const isFenceLine = /^\s*(?:` + '```' + String.raw`|~~~)/;`, + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + + test('valid: /^#\\s+/ single-line title-find is NOT flagged', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [ + { + code: String.raw`const titleRe = /^#\s+/;`, + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + + test('valid: /^###\\s+(.+?)\\s*$/ single-line heading-category match is NOT flagged', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [ + { + code: String.raw`const headingRe = /^###\s+(.+?)\s*$/;`, + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + + test('valid: /^(#{1,6})\\s+(.*)/ single-line heading match is NOT flagged', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [ + { + code: String.raw`const headingM = line.match(/^(#{1,6})\s+(.*)/);`, + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + + test('valid: seam usage (no regex, just an import reference) is NOT flagged', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [ + { + code: ` + const { collectSection } = require('./markdown-sectionizer'); + const result = collectSection(content, 'Introduction'); + `, + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + + test('valid: annotated fence-block-strip with allow-adhoc-markdown is NOT flagged', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [ + { + // Trailing annotation on the same line suppresses the finding + code: + 'const stripFences = /```' + + String.raw`[\s\S]*?` + + '`' + + '``/; // allow-adhoc-markdown: pre-seam write path; pending migration #1372', + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + + test('valid: rule is inert outside src/*.cts files', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [ + { + // Same fence-block-strip regex in a test file → rule does not apply + code: String.raw`const stripFences = /~~~[\s\S]*?~~~/;`, + filename: 'tests/some.test.cjs', + }, + { + // Same regex in a scripts file → rule does not apply + code: String.raw`const p = /(##\s*X\n)([\s\S]*?)(?=\n##|$)/;`, + filename: 'scripts/helper.cjs', + }, + ], + invalid: [], + }); + }); +});