ci(#1401): add no-adhoc-markdown-parsing rule (fence-strip + section-collect) + grandfather burn-down (#1402)

Tightens the over-broad heading-walk detection: removes heading-walk
entirely and narrows fence-regex to require a multiline body ([\s\S]),
so single-line tests like /^```/ and /^###\s+/ are no longer flagged.
Grandfathers the 10 genuine section-collect sites across state.cts,
milestone.cts, audit.cts, and phase-lifecycle.cts with concrete reasons.
Adds 12 RuleTester tests (3 positive, 9 negative) to eslint-rules.test.cjs.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-17 20:14:12 -04:00
committed by GitHub
parent ce8bcb1b95
commit 666d933e16
7 changed files with 315 additions and 9 deletions

View File

@@ -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: <reason> 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: <reason>',
sectionCollect:
'Ad-hoc section-collect regex detected (heading + [\\s\\S]*? + lookahead). Import collectSection() from ./markdown-sectionizer instead. Suppress with: // allow-adhoc-markdown: <reason>',
},
},
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: <reason>
* 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;

View File

@@ -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: <reason>
'local/no-adhoc-markdown-parsing': 'error',
},
},

View File

@@ -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)) {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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) || [];

View File

@@ -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: [],
});
});
});