@dotdotgod/cli 0.1.50 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.1.50",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for dotdotgod validation, project memory snapshots, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
package/src/core.mjs CHANGED
@@ -13,7 +13,7 @@ import { runTrelloSync } from './trello/sync.mjs';
13
13
  export { CACHE_VERSION } from './index/constants.mjs';
14
14
  export { commandUsage, hasHelpToken, helpCommandFromArgs, isHelpToken, isVersionToken, parseCommon, printVersion, usage } from './cli/usage.mjs';
15
15
  export { rel } from './common/paths.mjs';
16
- export { extractAnchors, extractLinks, headingToAnchor, isKebabCase, isUpperSnakeMarkdown, removeCodeBlocks } from './docs/markdown.mjs';
16
+ export { extractAnchors, extractFirstHeading, extractLinks, headingToAnchor, isKebabCase, isNumberedSeriesFilename, isUpperSnakeMarkdown, removeCodeBlocks, suggestFilenameFromHeading } from './docs/markdown.mjs';
17
17
  export { extractDotdotgodTraceabilityBlocks, findTraceabilityLinksRegion, isLocalRelativeTraceabilityPath, renderCompactTraceabilityBlock, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from './docs/traceability.mjs';
18
18
  export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_INTEGRATIONS_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneIntegrationsPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, isValidPathPattern, matchPathPattern, memoryAreaForPath, memoryConfigSummary, memoryRoleForPath, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, retrievalPriorityForPath, trelloSyncPaths, validateMemoryConfigData } from './memory/config.mjs';
19
19
  export { buildCommunities, relationWeight } from './graph/communities.mjs';
@@ -34,6 +34,46 @@ export function headingToAnchor(text) {
34
34
  .replace(/\s+/g, '-');
35
35
  }
36
36
 
37
+ export function isNumberedSeriesFilename(name, siblingNames) {
38
+ const stem = name.replace(/\.md$/, '');
39
+ const parts = stem.split('_');
40
+ if (parts.length < 2) return false;
41
+ const lastSeg = parts[parts.length - 1];
42
+ const firstSeg = parts[0];
43
+ if (/^\d+$/.test(lastSeg)) {
44
+ const prefix = parts.slice(0, -1).join('_');
45
+ if (siblingNames.some(sib => {
46
+ if (sib === name) return false;
47
+ const s = sib.replace(/\.md$/, '').split('_');
48
+ return s.length >= 2 && /^\d+$/.test(s[s.length - 1]) && s.slice(0, -1).join('_') === prefix;
49
+ })) return true;
50
+ }
51
+ if (/^\d+$/.test(firstSeg)) {
52
+ const suffix = parts.slice(1).join('_');
53
+ if (siblingNames.some(sib => {
54
+ if (sib === name) return false;
55
+ const s = sib.replace(/\.md$/, '').split('_');
56
+ return s.length >= 2 && /^\d+$/.test(s[0]) && s.slice(1).join('_') === suffix;
57
+ })) return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ export function extractFirstHeading(content) {
63
+ const match = content.match(/^#+\s+(.+)$/m);
64
+ return match ? match[1].replace(/[`*_]/g, '').trim() : undefined;
65
+ }
66
+
67
+ export function suggestFilenameFromHeading(heading) {
68
+ if (!heading) return undefined;
69
+ const stem = heading
70
+ .replace(/[^A-Za-z0-9\s]/g, '')
71
+ .trim()
72
+ .replace(/\s+/g, '_')
73
+ .toUpperCase();
74
+ return stem.length >= 3 ? `${stem}.md` : undefined;
75
+ }
76
+
37
77
  export function extractAnchors(content) {
38
78
  const anchors = new Set();
39
79
  const seen = new Map();
@@ -11,7 +11,15 @@ export const DEFAULT_TRACEABILITY_POLICY = {
11
11
  exclude: ['**/README.md'],
12
12
  };
13
13
  export const DEFAULT_VALIDATION_POLICY = {
14
- markdown: { maxLines: 200, maxChars: 10000, exclude: [] },
14
+ markdown: {
15
+ maxLines: 200,
16
+ maxChars: 10000,
17
+ exclude: [],
18
+ filename: {
19
+ warnNumberedSeries: true,
20
+ allow: [],
21
+ },
22
+ },
15
23
  };
16
24
  export const DEFAULT_INTEGRATIONS_POLICY = {
17
25
  trello: { syncPaths: [] },
@@ -101,11 +109,15 @@ export function cloneTraceabilityPolicy(policy = DEFAULT_TRACEABILITY_POLICY) {
101
109
  }
102
110
 
103
111
  export function cloneValidationPolicy(policy = DEFAULT_VALIDATION_POLICY) {
112
+ const filename = policy.markdown?.filename;
104
113
  return {
105
114
  markdown: {
106
115
  maxLines: policy.markdown?.maxLines ?? DEFAULT_VALIDATION_POLICY.markdown.maxLines,
107
116
  maxChars: policy.markdown?.maxChars ?? DEFAULT_VALIDATION_POLICY.markdown.maxChars,
108
117
  exclude: [...(policy.markdown?.exclude ?? [])],
118
+ filename: filename
119
+ ? { warnNumberedSeries: filename.warnNumberedSeries ?? true, allow: [...(filename.allow ?? [])] }
120
+ : { ...DEFAULT_VALIDATION_POLICY.markdown.filename, allow: [] },
109
121
  },
110
122
  };
111
123
  }
@@ -221,11 +233,16 @@ function normalizeTraceabilityPolicy(raw) {
221
233
  function normalizeValidationPolicy(raw) {
222
234
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return cloneValidationPolicy();
223
235
  const markdown = raw.markdown && typeof raw.markdown === 'object' && !Array.isArray(raw.markdown) ? raw.markdown : {};
236
+ const filename = markdown.filename && typeof markdown.filename === 'object' && !Array.isArray(markdown.filename) ? markdown.filename : undefined;
224
237
  return cloneValidationPolicy({
225
238
  markdown: {
226
239
  maxLines: Number.isInteger(markdown.maxLines) ? markdown.maxLines : DEFAULT_VALIDATION_POLICY.markdown.maxLines,
227
240
  maxChars: Number.isInteger(markdown.maxChars) ? markdown.maxChars : DEFAULT_VALIDATION_POLICY.markdown.maxChars,
228
241
  exclude: Array.isArray(markdown.exclude) ? markdown.exclude.map(normalizePathPattern) : [],
242
+ filename: filename ? {
243
+ warnNumberedSeries: typeof filename.warnNumberedSeries === 'boolean' ? filename.warnNumberedSeries : true,
244
+ allow: Array.isArray(filename.allow) ? filename.allow.map(normalizePathPattern) : [],
245
+ } : undefined,
229
246
  },
230
247
  });
231
248
  }
@@ -314,6 +331,16 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
314
331
  if (markdown.maxChars !== undefined && (!Number.isInteger(markdown.maxChars) || markdown.maxChars < 1)) add('VALIDATION_CONFIG_INVALID_MAX_CHARS', 'validation.markdown.maxChars', 'Expected a positive integer.');
315
332
  if (markdown.exclude !== undefined && !Array.isArray(markdown.exclude)) add('VALIDATION_CONFIG_INVALID_EXCLUDE', 'validation.markdown.exclude', 'Expected an array of path strings.');
316
333
  else if (Array.isArray(markdown.exclude) && markdown.exclude.some((value) => !isValidPathPattern(value))) add('VALIDATION_CONFIG_INVALID_EXCLUDE', 'validation.markdown.exclude', 'Expected path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
334
+ if (markdown.filename !== undefined) {
335
+ const fn = markdown.filename;
336
+ if (!fn || typeof fn !== 'object' || Array.isArray(fn)) {
337
+ add('VALIDATION_CONFIG_INVALID_FILENAME', 'validation.markdown.filename', 'Expected an object.');
338
+ } else {
339
+ if (fn.warnNumberedSeries !== undefined && typeof fn.warnNumberedSeries !== 'boolean') add('VALIDATION_CONFIG_INVALID_FILENAME', 'validation.markdown.filename.warnNumberedSeries', 'Expected a boolean.');
340
+ if (fn.allow !== undefined && !Array.isArray(fn.allow)) add('VALIDATION_CONFIG_INVALID_FILENAME_ALLOW', 'validation.markdown.filename.allow', 'Expected an array of path strings.');
341
+ else if (Array.isArray(fn.allow) && fn.allow.some((value) => !isValidPathPattern(value))) add('VALIDATION_CONFIG_INVALID_FILENAME_ALLOW', 'validation.markdown.filename.allow', 'Expected path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
342
+ }
343
+ }
317
344
  }
318
345
  }
319
346
  }
@@ -2,9 +2,9 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { basename, dirname, extname, join, resolve } from 'node:path';
3
3
  import { usage } from '../cli/usage.mjs';
4
4
  import { rel } from '../common/paths.mjs';
5
- import { extractAnchors, extractLinks, isKebabCase, isUpperSnakeMarkdown } from '../docs/markdown.mjs';
5
+ import { extractAnchors, extractFirstHeading, extractLinks, isKebabCase, isNumberedSeriesFilename, isUpperSnakeMarkdown, suggestFilenameFromHeading } from '../docs/markdown.mjs';
6
6
  import { extractDotdotgodTraceabilityBlocks, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from '../docs/traceability.mjs';
7
- import { DEFAULT_VALIDATION_POLICY, cloneValidationPolicy, isMarkdownSizeExcluded, readMemoryConfig, requiresTraceability, resolveMemoryArea } from '../memory/config.mjs';
7
+ import { DEFAULT_VALIDATION_POLICY, cloneValidationPolicy, isMarkdownSizeExcluded, matchPathPattern, readMemoryConfig, requiresTraceability, resolveMemoryArea } from '../memory/config.mjs';
8
8
  import { cacheFile, collectIndexFiles, fingerprint } from '../index/files.mjs';
9
9
  import { CACHE_DIR, CACHE_VERSION } from '../index/constants.mjs';
10
10
  import { readIndex } from '../index/cache.mjs';
@@ -26,9 +26,11 @@ export function runValidate(argv) {
26
26
  const root = resolve(options.root);
27
27
  const docs = join(root, 'docs');
28
28
  const errors = [];
29
+ const warnings = [];
29
30
  const markdownFiles = [];
30
31
  const fileCache = new Map();
31
32
  const addError = (file, code, message, line = null, fix = null, prompt = null) => errors.push({ file: rel(root, file), line, code, message: fix ? `${message}\nFix: ${fix}` : message, ...(prompt ? { prompt } : {}) });
33
+ const addWarning = (file, code, message, metadata = {}) => warnings.push({ file: rel(root, file), code, message, ...metadata });
32
34
  const shouldSkipDir = (dir) => {
33
35
  const path = rel(root, dir);
34
36
  if (!path || path === '.') return false;
@@ -66,7 +68,32 @@ export function runValidate(argv) {
66
68
  return `Split ${path} into focused documents by documentation area and role. Keep the current ${areaText} area semantics and ${roleText} role intact, move role-specific detail into smaller UPPER_SNAKE_CASE markdown files, and update the nearest README.md index with each new file's purpose.`;
67
69
  };
68
70
  for (const error of memoryConfig.errors ?? []) errors.push(error);
71
+ const FILENAME_QUALITY_AREAS = new Set(
72
+ (memoryConfig.areas ?? [])
73
+ .filter(area => area.scope === 'shared')
74
+ .flatMap(area => area.paths ?? [])
75
+ .map(p => { const m = p.match(/^docs\/([^/*]+)\/\*\*$/); return m ? m[1] : null; })
76
+ .filter(Boolean)
77
+ );
78
+ const FILENAME_QUALITY_PROMPT =
79
+ 'Background: Filenames and paths are LLM/agent context signals used for impact ranking, search, and retrieval. ' +
80
+ 'Abstract or sequentially numbered filenames carry low information signal.\n' +
81
+ 'Naming principles:\n' +
82
+ '1. Filenames in docs/spec, docs/arch, docs/test should be meaning-based wherever possible.\n' +
83
+ '2. The filename alone should let you infer the primary subject: endpoint, screen, policy, state, or response type.\n' +
84
+ '3. When splitting documents, derive names from headings, API paths, or domain terms rather than sequence numbers or abstract words.\n' +
85
+ 'Avoid - numbered splits: API_1.md, API_2.md, API_3_1_*.md, 01_*.md, *_01.md\n' +
86
+ 'Avoid - overly abstract: COMMON.md, OVERVIEW.md, DETAILS.md, NOTES.md, MISC.md, PART_1.md, SECTION_1.md\n' +
87
+ 'Acceptable exceptions: README.md (directory index), files with clear HTTP status code semantics (e.g. RESPONSE_200_OK.md), ' +
88
+ 'files in the project validation.markdown.filename.allow allowlist.\n' +
89
+ 'Good renames: API_1.md → BIZ_RESERVATIONS_SCHEDULE.md, COMMON.md → BIZ_AUTH_ERROR_CONVENTIONS.md, OVERVIEW.md → REGISTRATION_HANDOFF_SUMMARY.md';
69
90
  walk(docs);
91
+ const dirSiblingMap = new Map();
92
+ for (const f of markdownFiles) {
93
+ const dir = dirname(f);
94
+ if (!dirSiblingMap.has(dir)) dirSiblingMap.set(dir, []);
95
+ dirSiblingMap.get(dir).push(basename(f));
96
+ }
70
97
  for (const file of markdownFiles) {
71
98
  const name = basename(file);
72
99
  const docsRel = rel(docs, file);
@@ -75,6 +102,23 @@ export function runValidate(argv) {
75
102
  if (docsRel && !docsRel.startsWith('..') && !isPlanInternalWorkspaceFile && !isUpperSnakeMarkdown(name)) addError(file, 'FILE_NAMING', `Markdown file must be UPPER_SNAKE_CASE.md or README.md: ${name}`, null, 'rename the markdown file to UPPER_SNAKE_CASE.md or README.md and update any links that reference it.');
76
103
  const content = readFileSync(file, 'utf8');
77
104
  fileCache.set(file, content);
105
+ const docArea = docsRel?.split('/')[0];
106
+ if (name !== 'README.md' && !isPlanInternalWorkspaceFile
107
+ && isUpperSnakeMarkdown(name) && FILENAME_QUALITY_AREAS.has(docArea)) {
108
+ const fp = validationPolicy.markdown?.filename ?? {};
109
+ const allow = fp.allow ?? [];
110
+ if (!allow.some((p) => matchPathPattern(rootRel, p)) && (fp.warnNumberedSeries ?? true)) {
111
+ const siblings = dirSiblingMap.get(dirname(file)) ?? [];
112
+ if (isNumberedSeriesFilename(name, siblings)) {
113
+ const heading = extractFirstHeading(content);
114
+ const suggestion = suggestFilenameFromHeading(heading);
115
+ addWarning(file, 'FILE_NAMING_NUMBERED',
116
+ `Numbered series filename: ${name}. Filenames are LLM context signals; use API path, domain, or subject instead of sequence numbers.`,
117
+ { pattern: 'numbered-series', ...(heading ? { heading } : {}), ...(suggestion ? { suggestion } : {}), prompt: FILENAME_QUALITY_PROMPT }
118
+ );
119
+ }
120
+ }
121
+ }
78
122
  const budgetContent = stripTraceabilityLinksRegion(content);
79
123
  const lines = budgetContent.split('\n').length;
80
124
  const chars = budgetContent.length;
@@ -159,11 +203,21 @@ export function runValidate(argv) {
159
203
  const content = readFileSync(gitignore, 'utf8').split('\n').map((line) => line.trim());
160
204
  for (const required of ['docs/plan', 'docs/archive', CACHE_DIR]) if (!content.includes(required)) addError(gitignore, 'MISSING_GITIGNORE_ENTRY', `Expected .gitignore entry: ${required}`, null, `add ${required} to .gitignore so local plans, archives, and cache files stay untracked.`);
161
205
  }
162
- if (options.json) console.log(JSON.stringify({ ok: errors.length === 0, errors }, null, 2));
163
- else if (errors.length === 0) console.log(`✅ docs validation passed (${markdownFiles.length} markdown files)`);
206
+ if (options.json) console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
164
207
  else {
165
- for (const error of errors) console.log(`${error.line ? `${error.file}:${error.line}` : error.file} [${error.code}] ${error.message}${error.prompt ? `\nPrompt: ${error.prompt}` : ''}`);
166
- console.log(`\n❌ ${errors.length} docs validation error(s)`);
208
+ for (const w of warnings) {
209
+ console.log(`⚠ ${w.file} [${w.code}] ${w.message}`);
210
+ if (w.heading) console.log(` Heading: "${w.heading}"`);
211
+ if (w.suggestion) console.log(` Suggestion: rename to ${w.suggestion} and update links`);
212
+ if (w.prompt) console.log(` Prompt: ${w.prompt}`);
213
+ }
214
+ if (errors.length === 0) {
215
+ const warnNote = warnings.length > 0 ? `, ${warnings.length} warning(s)` : '';
216
+ console.log(`✅ docs validation passed (${markdownFiles.length} markdown files${warnNote})`);
217
+ } else {
218
+ for (const error of errors) console.log(`${error.line ? `${error.file}:${error.line}` : error.file} [${error.code}] ${error.message}${error.prompt ? `\nPrompt: ${error.prompt}` : ''}`);
219
+ console.log(`\n❌ ${errors.length} docs validation error(s)`);
220
+ }
167
221
  }
168
222
  process.exit(errors.length === 0 ? 0 : 1);
169
223
  }