@ryuenn3123/agentic-senior-core 4.0.2 → 4.1.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/.agent-context/rules/api-docs.md +14 -0
- package/.agent-context/rules/api-versioning.md +93 -0
- package/.agent-context/rules/background-jobs.md +93 -0
- package/.agent-context/rules/config-and-flags.md +79 -0
- package/.agent-context/rules/database-design.md +32 -0
- package/.agent-context/rules/frontend-architecture.md +35 -0
- package/.agent-context/rules/migrations.md +84 -0
- package/.agent-context/rules/observability.md +69 -0
- package/.agent-context/rules/resilience.md +78 -0
- package/.agent-context/rules/security.md +28 -0
- package/AGENTS.md +8 -8
- package/README.md +42 -9
- package/bin/agentic-senior-core.js +6 -0
- package/lib/cli/audits/typography-palette-anti-repeat/color-utils.mjs +156 -0
- package/lib/cli/audits/typography-palette-anti-repeat/file-scanner.mjs +103 -0
- package/lib/cli/audits/typography-palette-anti-repeat/typography-utils.mjs +70 -0
- package/lib/cli/audits/typography-palette-anti-repeat-audit.mjs +255 -0
- package/lib/cli/commands/audit-design-anti-repeat.mjs +198 -0
- package/lib/cli/commands/upgrade.mjs +1 -0
- package/lib/cli/utils.mjs +1 -0
- package/package.json +4 -4
- package/scripts/audit-cache-layer-contract.mjs +5 -0
- package/scripts/audit-caching-scope-hygiene.mjs +5 -0
- package/scripts/audit-typography-palette-anti-repeat.mjs +120 -0
- package/scripts/clean-local-artifacts.mjs +0 -1
- package/scripts/frontend-usability-audit.mjs +5 -8
- package/scripts/release-gate/static-checks.mjs +7 -7
- package/scripts/validate/config.mjs +0 -2
- package/scripts/validate/coverage-checks.mjs +1 -42
- package/scripts/validate.mjs +42 -7
- package/scripts/migrate-rule-format/id-prefix-table.mjs +0 -37
- package/scripts/migrate-rule-format/parse-legacy.mjs +0 -180
- package/scripts/migrate-rule-format/render-new.mjs +0 -169
- package/scripts/migrate-rule-format/roundtrip-validate.mjs +0 -89
- package/scripts/migrate-rule-format.mjs +0 -192
- package/scripts/v3-purge-audit.mjs +0 -236
|
@@ -17,9 +17,7 @@ const __dirname = dirname(__filename);
|
|
|
17
17
|
const REPOSITORY_ROOT = resolve(__dirname, '..');
|
|
18
18
|
|
|
19
19
|
const REQUIRED_FILES = [
|
|
20
|
-
'docs/
|
|
21
|
-
'docs/archive/v1.7-issue-breakdown.md',
|
|
22
|
-
'docs/archive/v1.7-execution-playbook.md',
|
|
20
|
+
'docs/archive/HISTORY.md',
|
|
23
21
|
'AGENTS.md',
|
|
24
22
|
'.agent-context/prompts/bootstrap-design.md',
|
|
25
23
|
'scripts/ui-design-judge.mjs',
|
|
@@ -32,11 +30,10 @@ const REQUIRED_FILES = [
|
|
|
32
30
|
'lib/cli/detector/design-evidence.mjs',
|
|
33
31
|
];
|
|
34
32
|
|
|
35
|
-
const
|
|
33
|
+
const REQUIRED_HISTORY_SNIPPETS = [
|
|
36
34
|
'V1.7',
|
|
37
35
|
'Frontend Product Experience',
|
|
38
|
-
'
|
|
39
|
-
'Delivered Scope',
|
|
36
|
+
'completed',
|
|
40
37
|
];
|
|
41
38
|
|
|
42
39
|
const REQUIRED_PR_CHECKLIST_SNIPPETS = [
|
|
@@ -174,7 +171,7 @@ function runAudit() {
|
|
|
174
171
|
assertFileExists(requiredFilePath, failures);
|
|
175
172
|
}
|
|
176
173
|
|
|
177
|
-
const roadmapPath = 'docs/
|
|
174
|
+
const roadmapPath = 'docs/archive/HISTORY.md';
|
|
178
175
|
const frontendRulePath = '.agent-context/rules/frontend-architecture.md';
|
|
179
176
|
const bootstrapDesignPromptPath = '.agent-context/prompts/bootstrap-design.md';
|
|
180
177
|
const instructionsPath = 'AGENTS.md';
|
|
@@ -185,7 +182,7 @@ function runAudit() {
|
|
|
185
182
|
|
|
186
183
|
if (existsSync(resolve(REPOSITORY_ROOT, roadmapPath))) {
|
|
187
184
|
const roadmapContent = readFileSync(resolve(REPOSITORY_ROOT, roadmapPath), 'utf8');
|
|
188
|
-
assertContains('
|
|
185
|
+
assertContains('Project history', roadmapPath, roadmapContent, REQUIRED_HISTORY_SNIPPETS, failures);
|
|
189
186
|
}
|
|
190
187
|
|
|
191
188
|
if (existsSync(resolve(REPOSITORY_ROOT, prChecklistPath))) {
|
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
export function runStaticReleaseChecks(results, diagnostics) {
|
|
24
24
|
const packageJsonPath = 'package.json';
|
|
25
25
|
const changelogPath = 'CHANGELOG.md';
|
|
26
|
-
const roadmapPath = 'docs/roadmap.md';
|
|
27
26
|
|
|
28
27
|
const packageJsonContent = readText(packageJsonPath);
|
|
29
28
|
if (!packageJsonContent) {
|
|
@@ -61,13 +60,14 @@ export function runStaticReleaseChecks(results, diagnostics) {
|
|
|
61
60
|
pushResult(results, true, 'changelog-version-entry', `Found release header for ${releaseVersion}`);
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
const historyPath = 'docs/archive/HISTORY.md';
|
|
64
|
+
const historyContent = readText(historyPath);
|
|
65
|
+
if (!historyContent) {
|
|
66
|
+
pushResult(results, false, 'history-exists', `Missing ${historyPath}`);
|
|
67
|
+
} else if (!historyContent.includes('V1.8')) {
|
|
68
|
+
pushResult(results, false, 'history-v18', 'Project history does not mention V1.8 release track');
|
|
69
69
|
} else {
|
|
70
|
-
pushResult(results, true, '
|
|
70
|
+
pushResult(results, true, 'history-v18', 'Project history includes V1.8 release track');
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
try {
|
|
@@ -51,7 +51,6 @@ export const REQUIRED_HUMAN_WRITING_SNIPPETS = [
|
|
|
51
51
|
];
|
|
52
52
|
export const TERMINOLOGY_REFERENCE_PATHS = [
|
|
53
53
|
'README.md',
|
|
54
|
-
'docs/roadmap.md',
|
|
55
54
|
];
|
|
56
55
|
export const REQUIRED_TERMINOLOGY_ROW_PATTERNS = [
|
|
57
56
|
{
|
|
@@ -69,7 +68,6 @@ export const REQUIRED_TERMINOLOGY_ROW_PATTERNS = [
|
|
|
69
68
|
];
|
|
70
69
|
export const REQUIRED_TERMINOLOGY_RULE_SNIPPET =
|
|
71
70
|
'Rule: on first mention in developer-facing docs, include canonical term in parentheses.';
|
|
72
|
-
export const TERMINOLOGY_REFERENCE_DOCUMENT_PATH = 'docs/terminology-mapping.md';
|
|
73
71
|
export const REQUIRED_DEVELOPER_FIRST_MENTION_PATTERNS = [
|
|
74
72
|
{
|
|
75
73
|
path: 'README.md',
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
REQUIRED_UI_DESIGN_AUTOMATION_SNIPPETS,
|
|
20
20
|
REQUIRED_UNIVERSAL_SOP_SNIPPETS,
|
|
21
21
|
REQUIRED_UPGRADE_UI_CONTRACT_WARNING_SNIPPETS,
|
|
22
|
-
TERMINOLOGY_REFERENCE_DOCUMENT_PATH,
|
|
23
22
|
TERMINOLOGY_REFERENCE_PATHS,
|
|
24
23
|
THIN_ADAPTER_PATHS,
|
|
25
24
|
} from './config.mjs';
|
|
@@ -59,40 +58,6 @@ export async function validateTerminologyMapping(context) {
|
|
|
59
58
|
|
|
60
59
|
console.log('\nChecking terminology mapping consistency...');
|
|
61
60
|
|
|
62
|
-
const terminologyReferenceDocumentPath = join(ROOT_DIR, TERMINOLOGY_REFERENCE_DOCUMENT_PATH);
|
|
63
|
-
|
|
64
|
-
if (!(await fileExists(terminologyReferenceDocumentPath))) {
|
|
65
|
-
fail(`Missing terminology reference document: ${TERMINOLOGY_REFERENCE_DOCUMENT_PATH}`);
|
|
66
|
-
} else {
|
|
67
|
-
const terminologyReferenceContent = await readTextFile(terminologyReferenceDocumentPath);
|
|
68
|
-
|
|
69
|
-
if (terminologyReferenceContent.includes('Dual-Term Mapping')) {
|
|
70
|
-
pass(`${TERMINOLOGY_REFERENCE_DOCUMENT_PATH} includes Dual-Term Mapping section`);
|
|
71
|
-
} else {
|
|
72
|
-
fail(`${TERMINOLOGY_REFERENCE_DOCUMENT_PATH} must include Dual-Term Mapping section`);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
for (const terminologyRowRule of REQUIRED_TERMINOLOGY_ROW_PATTERNS) {
|
|
76
|
-
if (terminologyRowRule.pattern.test(terminologyReferenceContent)) {
|
|
77
|
-
pass(`${TERMINOLOGY_REFERENCE_DOCUMENT_PATH} includes mapping row: ${terminologyRowRule.label}`);
|
|
78
|
-
} else {
|
|
79
|
-
fail(`${TERMINOLOGY_REFERENCE_DOCUMENT_PATH} is missing mapping row: ${terminologyRowRule.label}`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (terminologyReferenceContent.includes('first mention must include canonical term in parentheses')) {
|
|
84
|
-
pass(`${TERMINOLOGY_REFERENCE_DOCUMENT_PATH} defines first-mention canonical term rule`);
|
|
85
|
-
} else {
|
|
86
|
-
fail(`${TERMINOLOGY_REFERENCE_DOCUMENT_PATH} must define first-mention canonical term rule`);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (terminologyReferenceContent.includes('Formal policy and audit artifacts must keep canonical terminology')) {
|
|
90
|
-
pass(`${TERMINOLOGY_REFERENCE_DOCUMENT_PATH} defines compliance terminology boundary`);
|
|
91
|
-
} else {
|
|
92
|
-
fail(`${TERMINOLOGY_REFERENCE_DOCUMENT_PATH} must define compliance terminology boundary`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
61
|
for (const terminologyReferencePath of TERMINOLOGY_REFERENCE_PATHS) {
|
|
97
62
|
const absoluteReferencePath = join(ROOT_DIR, terminologyReferencePath);
|
|
98
63
|
|
|
@@ -122,12 +87,6 @@ export async function validateTerminologyMapping(context) {
|
|
|
122
87
|
} else {
|
|
123
88
|
fail(`${terminologyReferencePath} must include first-mention canonical term rule`);
|
|
124
89
|
}
|
|
125
|
-
|
|
126
|
-
if (referenceContent.includes(TERMINOLOGY_REFERENCE_DOCUMENT_PATH)) {
|
|
127
|
-
pass(`${terminologyReferencePath} links to ${TERMINOLOGY_REFERENCE_DOCUMENT_PATH}`);
|
|
128
|
-
} else {
|
|
129
|
-
fail(`${terminologyReferencePath} must link to ${TERMINOLOGY_REFERENCE_DOCUMENT_PATH}`);
|
|
130
|
-
}
|
|
131
90
|
}
|
|
132
91
|
|
|
133
92
|
for (const firstMentionRule of REQUIRED_DEVELOPER_FIRST_MENTION_PATTERNS) {
|
|
@@ -397,7 +356,7 @@ export async function validateInstructionAdapters(context) {
|
|
|
397
356
|
const instructionFootprintLimits = [
|
|
398
357
|
{ path: 'AGENTS.md', maxLines: 180 },
|
|
399
358
|
{ path: '.agent-context/prompts/bootstrap-design.md', maxLines: 180 },
|
|
400
|
-
{ path: '.agent-context/rules/frontend-architecture.md', maxLines:
|
|
359
|
+
{ path: '.agent-context/rules/frontend-architecture.md', maxLines: 180 },
|
|
401
360
|
];
|
|
402
361
|
|
|
403
362
|
for (const requiredBootstrapReceiptSnippet of requiredBootstrapReceiptSnippets) {
|
package/scripts/validate.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
20
20
|
import { ALLOWED_SEVERITIES } from './validate/config.mjs';
|
|
21
21
|
import { runCacheLayerContractAudit } from './audit-cache-layer-contract.mjs';
|
|
22
22
|
import { runCachingScopeHygieneAudit } from './audit-caching-scope-hygiene.mjs';
|
|
23
|
+
import { runTypographyPaletteAntiRepeatAudit } from '../lib/cli/audits/typography-palette-anti-repeat-audit.mjs';
|
|
23
24
|
import { runAuditFileSize } from './audit-file-size.mjs';
|
|
24
25
|
import { runReflectionCitationAudit } from './audit-reflection-citations.mjs';
|
|
25
26
|
import { runReleaseBundleAudit } from './audit-release-bundle.mjs';
|
|
@@ -153,8 +154,10 @@ async function validateRequiredFiles() {
|
|
|
153
154
|
'scripts/explain-on-demand-audit.mjs',
|
|
154
155
|
'scripts/single-source-lazy-loading-audit.mjs',
|
|
155
156
|
'scripts/audit-cache-layer-contract.mjs',
|
|
157
|
+
'scripts/audit-typography-palette-anti-repeat.mjs',
|
|
158
|
+
'lib/cli/audits/typography-palette-anti-repeat-audit.mjs',
|
|
159
|
+
'lib/cli/commands/audit-design-anti-repeat.mjs',
|
|
156
160
|
'scripts/sync-thin-adapters.mjs',
|
|
157
|
-
'scripts/v3-purge-audit.mjs',
|
|
158
161
|
'scripts/release-gate.mjs',
|
|
159
162
|
'scripts/generate-sbom.mjs',
|
|
160
163
|
'.agent-context/policies/llm-judge-threshold.json',
|
|
@@ -171,11 +174,8 @@ async function validateRequiredFiles() {
|
|
|
171
174
|
'docs/api-contract.md',
|
|
172
175
|
'docs/faq.md',
|
|
173
176
|
'docs/deep-dive.md',
|
|
174
|
-
'docs/
|
|
175
|
-
'docs/archive/
|
|
176
|
-
'docs/archive/v1.7-issue-breakdown.md',
|
|
177
|
-
'docs/archive/v1.8-operations-playbook.md',
|
|
178
|
-
'docs/archive/v2-upgrade-playbook.md',
|
|
177
|
+
'docs/archive/HISTORY.md',
|
|
178
|
+
'docs/archive/CHANGELOG-archive.md',
|
|
179
179
|
'.agent-context/state/benchmark-reproducibility.json',
|
|
180
180
|
'.agent-context/state/benchmark-writer-judge-config.json',
|
|
181
181
|
'.agent-context/state/memory-schema-v1.json',
|
|
@@ -243,6 +243,12 @@ async function validateRuleFiles() {
|
|
|
243
243
|
'rules/realtime.md',
|
|
244
244
|
'rules/frontend-architecture.md',
|
|
245
245
|
'rules/docker-runtime.md',
|
|
246
|
+
'rules/observability.md',
|
|
247
|
+
'rules/resilience.md',
|
|
248
|
+
'rules/migrations.md',
|
|
249
|
+
'rules/background-jobs.md',
|
|
250
|
+
'rules/config-and-flags.md',
|
|
251
|
+
'rules/api-versioning.md',
|
|
246
252
|
'review-checklists/pr-checklist.md',
|
|
247
253
|
'review-checklists/architecture-review.md',
|
|
248
254
|
'prompts/init-project.md',
|
|
@@ -492,7 +498,7 @@ async function validateDocumentationFlow() {
|
|
|
492
498
|
'npm run validate',
|
|
493
499
|
'docs/faq.md',
|
|
494
500
|
'docs/deep-dive.md',
|
|
495
|
-
'docs/archive/
|
|
501
|
+
'docs/archive/HISTORY.md',
|
|
496
502
|
];
|
|
497
503
|
|
|
498
504
|
for (const requiredReadmeSnippet of requiredReadmeSnippets) {
|
|
@@ -569,6 +575,34 @@ async function validateCachingScopeHygieneAudit() {
|
|
|
569
575
|
}
|
|
570
576
|
}
|
|
571
577
|
|
|
578
|
+
async function validateTypographyPaletteAntiRepeatAudit() {
|
|
579
|
+
console.log('\nChecking typography and palette anti-repeat ledger (audit:typography-palette-anti-repeat)...');
|
|
580
|
+
const report = runTypographyPaletteAntiRepeatAudit({ repositoryRootPath: ROOT_DIR });
|
|
581
|
+
|
|
582
|
+
if (report.skipped) {
|
|
583
|
+
pass(`Typography/palette anti-repeat audit skipped: ${report.reason}`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (report.passed) {
|
|
588
|
+
pass(`Typography/palette anti-repeat audit clean: ${report.filesScanned} CSS/token file(s) scanned, 0 blocking typography violation(s), ${report.paletteFindingCount} palette finding(s) (${report.paletteSeverity})`);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
for (const violation of report.typographyViolations) {
|
|
593
|
+
fail(`Typography ledger violation [${violation.kind}] in ${violation.file}:${violation.line}: ${violation.detail}`);
|
|
594
|
+
}
|
|
595
|
+
if (report.paletteSeverity === 'blocking') {
|
|
596
|
+
for (const finding of report.paletteFindings) {
|
|
597
|
+
fail(`Palette ledger violation [${finding.kind}] in ${finding.file}:${finding.line}: ${finding.detail}`);
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
for (const finding of report.paletteFindings) {
|
|
601
|
+
warn(`Palette ledger advisory [${finding.kind}] in ${finding.file}:${finding.line}: ${finding.detail}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
572
606
|
async function validateReleaseBundleAudit() {
|
|
573
607
|
console.log('\nChecking release benchmark bundle (audit:release-bundle)...');
|
|
574
608
|
const report = runReleaseBundleAudit();
|
|
@@ -666,6 +700,7 @@ async function main() {
|
|
|
666
700
|
await validateCacheLayerContractAudit();
|
|
667
701
|
await validateReflectionCitationAudit();
|
|
668
702
|
await validateCachingScopeHygieneAudit();
|
|
703
|
+
await validateTypographyPaletteAntiRepeatAudit();
|
|
669
704
|
await validateReleaseBundleAudit();
|
|
670
705
|
await validateFileSizeAudit();
|
|
671
706
|
await validateRuleIdUniquenessAudit();
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Locked ID prefix table per `docs/architecture/format-spec.md` section 3.
|
|
5
|
-
* The migration helper reads this map to assign frontmatter and section IDs.
|
|
6
|
-
* Lock new entries here when adding a new rule file; never invent prefixes inline.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export const ID_PREFIX_TABLE = Object.freeze({
|
|
10
|
-
'api-docs.md': { prefix: 'API', domain: 'api-docs', priority: 'high', scope: 'backend', appliesTo: ['backend', 'fullstack'] },
|
|
11
|
-
'architecture.md': { prefix: 'ARCH', domain: 'architecture', priority: 'critical', scope: 'all-tasks', appliesTo: ['backend', 'frontend', 'fullstack'] },
|
|
12
|
-
'database-design.md': { prefix: 'DATA', domain: 'database-design', priority: 'high', scope: 'data', appliesTo: ['backend', 'fullstack'] },
|
|
13
|
-
'docker-runtime.md': { prefix: 'DOCK', domain: 'docker-runtime', priority: 'high', scope: 'infra', appliesTo: ['backend', 'frontend', 'fullstack'] },
|
|
14
|
-
'efficiency-vs-hype.md': { prefix: 'DEP', domain: 'efficiency-vs-hype', priority: 'medium', scope: 'all-tasks', appliesTo: ['backend', 'frontend', 'fullstack'] },
|
|
15
|
-
'error-handling.md': { prefix: 'ERR', domain: 'error-handling', priority: 'high', scope: 'all-tasks', appliesTo: ['backend', 'frontend', 'fullstack'] },
|
|
16
|
-
'event-driven.md': { prefix: 'EVT', domain: 'event-driven', priority: 'medium', scope: 'backend', appliesTo: ['backend', 'fullstack'] },
|
|
17
|
-
'frontend-architecture.md': { prefix: 'FE', domain: 'frontend-architecture', priority: 'high', scope: 'ui', appliesTo: ['frontend', 'fullstack'] },
|
|
18
|
-
'git-workflow.md': { prefix: 'GIT', domain: 'git-workflow', priority: 'medium', scope: 'governance', appliesTo: ['backend', 'frontend', 'fullstack'] },
|
|
19
|
-
'microservices.md': { prefix: 'SVC', domain: 'microservices', priority: 'medium', scope: 'backend', appliesTo: ['backend', 'fullstack'] },
|
|
20
|
-
'naming-conv.md': { prefix: 'NAME', domain: 'naming-conv', priority: 'medium', scope: 'all-tasks', appliesTo: ['backend', 'frontend', 'fullstack'] },
|
|
21
|
-
'performance.md': { prefix: 'PERF', domain: 'performance', priority: 'medium', scope: 'all-tasks', appliesTo: ['backend', 'frontend', 'fullstack'] },
|
|
22
|
-
'realtime.md': { prefix: 'RT', domain: 'realtime', priority: 'medium', scope: 'backend', appliesTo: ['backend', 'fullstack'] },
|
|
23
|
-
'security.md': { prefix: 'SEC', domain: 'security', priority: 'critical', scope: 'all-tasks', appliesTo: ['backend', 'frontend', 'fullstack'] },
|
|
24
|
-
'testing.md': { prefix: 'TEST', domain: 'testing', priority: 'high', scope: 'all-tasks', appliesTo: ['backend', 'frontend', 'fullstack'] },
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* @param {string} filename
|
|
29
|
-
* @returns {{ prefix: string, domain: string, priority: string, scope: string, appliesTo: string[] }}
|
|
30
|
-
*/
|
|
31
|
-
export function getPrefixEntry(filename) {
|
|
32
|
-
const entry = ID_PREFIX_TABLE[filename];
|
|
33
|
-
if (!entry) {
|
|
34
|
-
throw new Error(`Unknown rule file '${filename}'. Add it to ID_PREFIX_TABLE before migrating.`);
|
|
35
|
-
}
|
|
36
|
-
return entry;
|
|
37
|
-
}
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Best-effort parser for the legacy v3 rule file format. Extracts:
|
|
5
|
-
* - the H1 title
|
|
6
|
-
* - an optional intro paragraph (1-3 sentences before the first H2)
|
|
7
|
-
* - a list of sections, each with H2 title + ordered content blocks
|
|
8
|
-
*
|
|
9
|
-
* Each content block is one of:
|
|
10
|
-
* { kind: 'paragraph', text }
|
|
11
|
-
* { kind: 'bullet-list', items: string[] }
|
|
12
|
-
* { kind: 'sub-bullet-list', items: string[] } // legacy nested bullets
|
|
13
|
-
*
|
|
14
|
-
* The parser intentionally throws on shapes it cannot represent in the new
|
|
15
|
-
* format. This forces the human migrator to review unusual sections instead of
|
|
16
|
-
* silently losing content.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @typedef {{ kind: 'paragraph', text: string }} ParagraphBlock
|
|
21
|
-
* @typedef {{ kind: 'bullet-list', items: string[] }} BulletListBlock
|
|
22
|
-
* @typedef {ParagraphBlock | BulletListBlock} ContentBlock
|
|
23
|
-
*
|
|
24
|
-
* @typedef {{
|
|
25
|
-
* title: string,
|
|
26
|
-
* blocks: ContentBlock[],
|
|
27
|
-
* }} ParsedSection
|
|
28
|
-
*
|
|
29
|
-
* @typedef {{
|
|
30
|
-
* h1Title: string,
|
|
31
|
-
* introParagraph: string | null,
|
|
32
|
-
* sections: ParsedSection[],
|
|
33
|
-
* warnings: string[],
|
|
34
|
-
* }} ParsedRuleFile
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* @param {string} sourceText
|
|
39
|
-
* @returns {ParsedRuleFile}
|
|
40
|
-
*/
|
|
41
|
-
export function parseLegacyRuleFile(sourceText) {
|
|
42
|
-
const lines = sourceText.replace(/\r\n/g, '\n').split('\n');
|
|
43
|
-
const warnings = [];
|
|
44
|
-
const isH2 = (line) => line.startsWith('## ');
|
|
45
|
-
const isH1 = (line) => line.startsWith('# ');
|
|
46
|
-
const isColonSectionLabel = (line) => /^[A-Z][^:\n]+:$/.test(line.trim());
|
|
47
|
-
|
|
48
|
-
let cursor = 0;
|
|
49
|
-
while (cursor < lines.length && lines[cursor].trim() === '') {
|
|
50
|
-
cursor += 1;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const h1Match = (lines[cursor] || '').match(/^#\s+(.+)$/);
|
|
54
|
-
if (!h1Match) {
|
|
55
|
-
throw new Error('Legacy file missing top-level H1 heading at the first non-empty line.');
|
|
56
|
-
}
|
|
57
|
-
const h1Title = h1Match[1].trim();
|
|
58
|
-
cursor += 1;
|
|
59
|
-
|
|
60
|
-
while (cursor < lines.length && lines[cursor].trim() === '') {
|
|
61
|
-
cursor += 1;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
let introParagraph = null;
|
|
65
|
-
if (cursor < lines.length && !lines[cursor].startsWith('## ') && !lines[cursor].startsWith('# ')) {
|
|
66
|
-
const introLines = [];
|
|
67
|
-
while (cursor < lines.length && !lines[cursor].startsWith('## ') && !lines[cursor].startsWith('# ')) {
|
|
68
|
-
const line = lines[cursor];
|
|
69
|
-
if (line.trim() === '' && introLines.length > 0) {
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
if (line.trim() !== '') {
|
|
73
|
-
introLines.push(line.trim());
|
|
74
|
-
}
|
|
75
|
-
cursor += 1;
|
|
76
|
-
}
|
|
77
|
-
if (introLines.length > 0) {
|
|
78
|
-
introParagraph = introLines.join(' ').trim();
|
|
79
|
-
const sentenceCount = (introParagraph.match(/[.!?](?=\s|$)/g) || []).length;
|
|
80
|
-
if (sentenceCount > 3) {
|
|
81
|
-
warnings.push(`Intro paragraph has ${sentenceCount} sentences (max 3 per format spec). Trim or split during manual review.`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
while (cursor < lines.length && lines[cursor].trim() === '') {
|
|
87
|
-
cursor += 1;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** @type {ParsedSection[]} */
|
|
91
|
-
const sections = [];
|
|
92
|
-
while (cursor < lines.length) {
|
|
93
|
-
while (cursor < lines.length && lines[cursor].trim() === '') {
|
|
94
|
-
cursor += 1;
|
|
95
|
-
}
|
|
96
|
-
if (cursor >= lines.length) {
|
|
97
|
-
break;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
let sectionTitle = '';
|
|
101
|
-
if (isH2(lines[cursor])) {
|
|
102
|
-
sectionTitle = lines[cursor].slice(3).trim();
|
|
103
|
-
cursor += 1;
|
|
104
|
-
} else if (isColonSectionLabel(lines[cursor])) {
|
|
105
|
-
sectionTitle = lines[cursor].trim().replace(/:$/, '');
|
|
106
|
-
cursor += 1;
|
|
107
|
-
} else if (!isH1(lines[cursor])) {
|
|
108
|
-
sectionTitle = sections.length === 0 ? 'General Guidance' : 'Boundary Summary';
|
|
109
|
-
} else {
|
|
110
|
-
cursor += 1;
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** @type {ContentBlock[]} */
|
|
115
|
-
const blocks = [];
|
|
116
|
-
while (cursor < lines.length && !isH2(lines[cursor]) && !isH1(lines[cursor]) && !isColonSectionLabel(lines[cursor])) {
|
|
117
|
-
const line = lines[cursor];
|
|
118
|
-
|
|
119
|
-
if (line.trim() === '') {
|
|
120
|
-
cursor += 1;
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (/^\s*-\s+/.test(line)) {
|
|
125
|
-
const items = [];
|
|
126
|
-
let nestedItems = [];
|
|
127
|
-
while (cursor < lines.length && (/^\s*-\s+/.test(lines[cursor]) || lines[cursor].trim() === '' || /^\s{2,}\S/.test(lines[cursor]))) {
|
|
128
|
-
const bulletLine = lines[cursor];
|
|
129
|
-
if (bulletLine.trim() === '') {
|
|
130
|
-
cursor += 1;
|
|
131
|
-
if (cursor < lines.length && !/^\s*-\s+/.test(lines[cursor])) {
|
|
132
|
-
break;
|
|
133
|
-
}
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
const topMatch = bulletLine.match(/^-\s+(.+)$/);
|
|
137
|
-
const nestedMatch = bulletLine.match(/^\s{2,}-\s+(.+)$/);
|
|
138
|
-
const continuationMatch = bulletLine.match(/^\s{2,}(\S.+)$/);
|
|
139
|
-
if (topMatch) {
|
|
140
|
-
if (nestedItems.length > 0 && items.length > 0) {
|
|
141
|
-
items[items.length - 1] += `\n ${nestedItems.map((nested) => `- ${nested}`).join('\n ')}`;
|
|
142
|
-
nestedItems = [];
|
|
143
|
-
}
|
|
144
|
-
items.push(topMatch[1].trim());
|
|
145
|
-
} else if (nestedMatch) {
|
|
146
|
-
nestedItems.push(nestedMatch[1].trim());
|
|
147
|
-
} else if (continuationMatch && items.length > 0) {
|
|
148
|
-
items[items.length - 1] += ` ${continuationMatch[1].trim()}`;
|
|
149
|
-
} else {
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
cursor += 1;
|
|
153
|
-
}
|
|
154
|
-
if (nestedItems.length > 0 && items.length > 0) {
|
|
155
|
-
items[items.length - 1] += `\n ${nestedItems.map((nested) => `- ${nested}`).join('\n ')}`;
|
|
156
|
-
}
|
|
157
|
-
blocks.push({ kind: 'bullet-list', items });
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const paragraphLines = [];
|
|
162
|
-
while (
|
|
163
|
-
cursor < lines.length
|
|
164
|
-
&& lines[cursor].trim() !== ''
|
|
165
|
-
&& !isH2(lines[cursor])
|
|
166
|
-
&& !isH1(lines[cursor])
|
|
167
|
-
&& !isColonSectionLabel(lines[cursor])
|
|
168
|
-
&& !/^\s*-\s+/.test(lines[cursor])
|
|
169
|
-
) {
|
|
170
|
-
paragraphLines.push(lines[cursor].trim());
|
|
171
|
-
cursor += 1;
|
|
172
|
-
}
|
|
173
|
-
blocks.push({ kind: 'paragraph', text: paragraphLines.join(' ').trim() });
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
sections.push({ title: sectionTitle, blocks });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return { h1Title, introParagraph, sections, warnings };
|
|
180
|
-
}
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Renders a parsed legacy rule file plus a prefix-table entry into the v4
|
|
5
|
-
* canonical format defined in `docs/architecture/format-spec.md`.
|
|
6
|
-
*
|
|
7
|
-
* Section IDs auto-assign sequentially starting at 001. The renderer never
|
|
8
|
-
* skips integers; humans introduce gaps manually during review by editing
|
|
9
|
-
* the produced file (e.g. when expecting later splits).
|
|
10
|
-
*
|
|
11
|
-
* Each parsed bullet-list becomes one numbered item if it has 1-2 items, or a
|
|
12
|
-
* single numbered item with sub-bullets when the list is enumerative (3+
|
|
13
|
-
* items that share the same shape).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { stringify as stringifyYaml } from 'yaml';
|
|
17
|
-
|
|
18
|
-
function pickKeywords(parsedRuleFile, prefixEntry) {
|
|
19
|
-
// Hand-picked first: the file's id_prefix lowercased and the domain itself
|
|
20
|
-
// are always relevant. Additional keywords are drawn from the highest-signal
|
|
21
|
-
// kebab-case tokens in the H1 + section titles, capped at 6 total. The
|
|
22
|
-
// validate gate snippet checks accept either body presence or this array, so
|
|
23
|
-
// we prioritize tokens that appear in section titles (more likely to be
|
|
24
|
-
// queried) over tokens buried in paragraphs.
|
|
25
|
-
const handPicked = new Set([prefixEntry.domain, prefixEntry.prefix.toLowerCase()]);
|
|
26
|
-
const titleSignal = parsedRuleFile.h1Title + ' ' + parsedRuleFile.sections.map((section) => section.title).join(' ');
|
|
27
|
-
for (const word of titleSignal.toLowerCase().match(/[a-z][a-z0-9]+(?:-[a-z0-9]+)*/g) ?? []) {
|
|
28
|
-
if (word.length >= 4 && word.length <= 32 && handPicked.size < 6) {
|
|
29
|
-
handPicked.add(word);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return [...handPicked];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function renderFrontmatter(prefixEntry, parsedRuleFile) {
|
|
36
|
-
// Trimmed v4 frontmatter (per phase-1-format.md GATE B revision):
|
|
37
|
-
// - drop `version` for first-time-v1 files (only meaningful when bumped)
|
|
38
|
-
// - drop `last_migrated` (git history is the audit trail)
|
|
39
|
-
// - cap `keywords` at 6 hand-picked entries instead of 12 auto-extracted
|
|
40
|
-
const frontmatterObject = {
|
|
41
|
-
id_prefix: prefixEntry.prefix,
|
|
42
|
-
domain: prefixEntry.domain,
|
|
43
|
-
priority: prefixEntry.priority,
|
|
44
|
-
scope: prefixEntry.scope,
|
|
45
|
-
applies_to: [...prefixEntry.appliesTo],
|
|
46
|
-
keywords: pickKeywords(parsedRuleFile, prefixEntry),
|
|
47
|
-
};
|
|
48
|
-
const yamlBody = stringifyYaml(frontmatterObject, { lineWidth: 0 }).trimEnd();
|
|
49
|
-
return `---\n${yamlBody}\n---\n`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function renderIntroParagraph(parsedRuleFile) {
|
|
53
|
-
if (!parsedRuleFile.introParagraph) return '';
|
|
54
|
-
return `${parsedRuleFile.introParagraph}\n\n`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Common abbreviations that end with a period but are not sentence endings.
|
|
58
|
-
// Mid-sentence occurrences like "etc. The next..." would otherwise be split
|
|
59
|
-
// at the abbreviation. Pre-masking is the cheapest fix and is easy to extend.
|
|
60
|
-
const NON_SENTENCE_ENDING_ABBREVIATIONS = Object.freeze(['e.g', 'i.e', 'etc', 'vs', 'cf', 'Mr', 'Dr', 'Mrs', 'Inc', 'Ltd']);
|
|
61
|
-
const ABBREVIATION_MASK_TOKEN = '\u0001';
|
|
62
|
-
|
|
63
|
-
function maskAbbreviationPeriods(paragraphText) {
|
|
64
|
-
let masked = paragraphText;
|
|
65
|
-
for (const abbreviation of NON_SENTENCE_ENDING_ABBREVIATIONS) {
|
|
66
|
-
const escaped = abbreviation.replace(/\./g, '\\.');
|
|
67
|
-
masked = masked.replace(new RegExp(`\\b${escaped}\\.`, 'g'), `${abbreviation}${ABBREVIATION_MASK_TOKEN}`);
|
|
68
|
-
}
|
|
69
|
-
return masked;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function unmaskAbbreviationPeriods(text) {
|
|
73
|
-
return text.replace(new RegExp(ABBREVIATION_MASK_TOKEN, 'g'), '.');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function paragraphSplitsIntoDirectives(paragraphText) {
|
|
77
|
-
// A `.` `!` or `?` ends a sentence only when it is followed by whitespace
|
|
78
|
-
// and an uppercase letter, a backtick (next clause starts with `code`), or
|
|
79
|
-
// an opening parenthesis. This rule recognizes:
|
|
80
|
-
// - file paths "docs/DESIGN.md" period + lowercase, no whitespace -> not a boundary
|
|
81
|
-
// - dotted versions "v1.5", "2.0.0" period + digit -> not a boundary
|
|
82
|
-
// - domain literals "example.com" period + lowercase -> not a boundary
|
|
83
|
-
// - abbreviations "e.g.", "i.e.", "etc." pre-masked so their internal periods do not split
|
|
84
|
-
// Everything else is treated as sentence-final.
|
|
85
|
-
const masked = maskAbbreviationPeriods(paragraphText);
|
|
86
|
-
const SENTENCE_BOUNDARY = /([.!?])\s+(?=[A-Z`(])/g;
|
|
87
|
-
const sentences = [];
|
|
88
|
-
let cursor = 0;
|
|
89
|
-
for (const match of masked.matchAll(SENTENCE_BOUNDARY)) {
|
|
90
|
-
const sentenceEnd = match.index + match[1].length;
|
|
91
|
-
sentences.push(unmaskAbbreviationPeriods(masked.slice(cursor, sentenceEnd)).trim());
|
|
92
|
-
cursor = match.index + match[0].length;
|
|
93
|
-
}
|
|
94
|
-
const tail = unmaskAbbreviationPeriods(masked.slice(cursor)).trim();
|
|
95
|
-
if (tail.length > 0) {
|
|
96
|
-
sentences.push(tail);
|
|
97
|
-
}
|
|
98
|
-
return sentences.filter((sentence) => sentence.length > 0);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function renderBlockAsNumberedItem(block) {
|
|
102
|
-
if (block.kind === 'paragraph') {
|
|
103
|
-
return paragraphSplitsIntoDirectives(block.text);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Each bullet becomes its own numbered directive. The format spec allows
|
|
107
|
-
// sub-bullets only as supporting detail under one parent directive, never
|
|
108
|
-
// as a way to compress an enumerative list into a single item. Keeping them
|
|
109
|
-
// as numbered items preserves citability (each becomes a sub-ID candidate
|
|
110
|
-
// during manual review) and matches the worked example in section 6.2.
|
|
111
|
-
return [...block.items];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function buildSectionBody(blocks) {
|
|
115
|
-
const numberedDirectives = [];
|
|
116
|
-
for (const block of blocks) {
|
|
117
|
-
const directives = renderBlockAsNumberedItem(block);
|
|
118
|
-
for (const directive of directives) {
|
|
119
|
-
numberedDirectives.push(directive);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return numberedDirectives;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* @param {{ prefix: string, domain: string, priority: string, scope: string, appliesTo: string[] }} prefixEntry
|
|
127
|
-
* @param {ReturnType<typeof import('./parse-legacy.mjs').parseLegacyRuleFile>} parsedRuleFile
|
|
128
|
-
* @returns {{ rendered: string, sectionAssignments: Array<{ sectionTitle: string, sectionId: string, itemCount: number }>, warnings: string[] }}
|
|
129
|
-
*/
|
|
130
|
-
export function renderNewFormat(prefixEntry, parsedRuleFile) {
|
|
131
|
-
const warnings = [...parsedRuleFile.warnings];
|
|
132
|
-
const renderedParts = [];
|
|
133
|
-
renderedParts.push(renderFrontmatter(prefixEntry, parsedRuleFile));
|
|
134
|
-
renderedParts.push('\n');
|
|
135
|
-
renderedParts.push(`# ${parsedRuleFile.h1Title}\n\n`);
|
|
136
|
-
renderedParts.push(renderIntroParagraph(parsedRuleFile));
|
|
137
|
-
|
|
138
|
-
const sectionAssignments = [];
|
|
139
|
-
parsedRuleFile.sections.forEach((section, sectionIndex) => {
|
|
140
|
-
const sectionId = `${prefixEntry.prefix}-${String(sectionIndex + 1).padStart(3, '0')}`;
|
|
141
|
-
const numberedItems = buildSectionBody(section.blocks);
|
|
142
|
-
if (numberedItems.length > 12) {
|
|
143
|
-
warnings.push(
|
|
144
|
-
`Section "${section.title}" has ${numberedItems.length} numbered items. Format spec caps at 12; split into two sections during manual review.`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
if (numberedItems.length === 0) {
|
|
148
|
-
warnings.push(`Section "${section.title}" produced no numbered items. Manual review required.`);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
renderedParts.push(`## ${sectionId}: ${section.title}\n\n`);
|
|
152
|
-
numberedItems.forEach((directive, itemIndex) => {
|
|
153
|
-
renderedParts.push(`${itemIndex + 1}. ${directive}\n`);
|
|
154
|
-
});
|
|
155
|
-
renderedParts.push('\n');
|
|
156
|
-
|
|
157
|
-
sectionAssignments.push({
|
|
158
|
-
sectionTitle: section.title,
|
|
159
|
-
sectionId,
|
|
160
|
-
itemCount: numberedItems.length,
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
return {
|
|
165
|
-
rendered: renderedParts.join('').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n',
|
|
166
|
-
sectionAssignments,
|
|
167
|
-
warnings,
|
|
168
|
-
};
|
|
169
|
-
}
|