proteum 2.5.9 → 2.5.11

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.
Files changed (41) hide show
  1. package/AGENTS.md +4 -4
  2. package/agents/project/AGENTS.md +131 -91
  3. package/agents/project/CODING_STYLE.md +69 -40
  4. package/agents/project/DOCUMENTATION.md +19 -2
  5. package/agents/project/client/AGENTS.md +0 -1
  6. package/agents/project/diagnostics.md +6 -5
  7. package/agents/project/optimizations.md +1 -7
  8. package/agents/project/server/services/AGENTS.md +1 -3
  9. package/agents/project/tests/AGENTS.md +3 -3
  10. package/cli/commands/docs.ts +223 -0
  11. package/cli/commands/session.ts +36 -5
  12. package/cli/commands/verify.ts +6 -1
  13. package/cli/compiler/client/index.ts +11 -4
  14. package/cli/compiler/common/uiSingletons.ts +76 -0
  15. package/cli/compiler/server/index.ts +34 -12
  16. package/cli/presentation/commands.ts +20 -1
  17. package/cli/runtime/commands.ts +20 -0
  18. package/cli/scaffold/index.ts +3 -0
  19. package/cli/scaffold/templates.ts +62 -6
  20. package/cli/utils/agents.ts +2 -2
  21. package/cli/verification/changed.ts +21 -0
  22. package/client/dev/profiler/index.tsx +761 -455
  23. package/common/dev/mcpPayloads.ts +86 -8
  24. package/common/dev/session.ts +32 -0
  25. package/common/errors/index.tsx +0 -1
  26. package/docAnchors.js +135 -0
  27. package/docs/agent-routing.md +2 -2
  28. package/eslint.js +264 -1
  29. package/package.json +1 -1
  30. package/server/app/container/console/index.ts +0 -17
  31. package/server/services/router/http/index.ts +130 -33
  32. package/tests/agents-utils.test.cjs +0 -4
  33. package/tests/dev-session-login-url.test.cjs +33 -0
  34. package/tests/doc-anchors.test.cjs +115 -0
  35. package/tests/docs-check.test.cjs +138 -0
  36. package/tests/eslint-rules.test.cjs +235 -2
  37. package/tests/mcp.test.cjs +109 -0
  38. package/tests/ui-singletons.test.cjs +104 -0
  39. package/tests/verify-changed.test.cjs +51 -3
  40. package/agents/project/app-root/AGENTS.md +0 -14
  41. package/agents/project/root/AGENTS.md +0 -399
@@ -45,8 +45,34 @@ type TNodePath = {
45
45
  resolve: (...segments: string[]) => string;
46
46
  };
47
47
 
48
+ type TDocAnchorEntry = {
49
+ line: number;
50
+ tag: string;
51
+ value: string;
52
+ };
53
+
54
+ type TDocAnchorGroups = {
55
+ adr: string[];
56
+ docs: string[];
57
+ entries: TDocAnchorEntry[];
58
+ fix: string[];
59
+ rules: string[];
60
+ };
61
+
62
+ type TDocAnchorsModule = {
63
+ collectDocAnchors: (sourceText: string, options?: { maxLength?: number }) => TDocAnchorGroups;
64
+ };
65
+
66
+ export type TOwnerDocAnchors = {
67
+ adr?: string[];
68
+ docs?: string[];
69
+ fix?: string[];
70
+ rules?: string[];
71
+ };
72
+
48
73
  const maxInstructionPreviewLength = 360;
49
74
  const maxTextLength = 220;
75
+ const maxOwnerDocAnchorRules = 3;
50
76
  const nodeRequire = (() => {
51
77
  try {
52
78
  return eval('require') as NodeRequire;
@@ -56,6 +82,23 @@ const nodeRequire = (() => {
56
82
  })();
57
83
  const fs = nodeRequire ? (nodeRequire('fs') as TNodeFs) : undefined;
58
84
  const path = nodeRequire ? (nodeRequire('path') as TNodePath) : undefined;
85
+ const docAnchors = (() => {
86
+ if (!nodeRequire) return undefined;
87
+
88
+ // Relative resolution covers the linked framework checkout; the package
89
+ // specifier covers an installed copy whose transpiled layout may differ.
90
+ // Owner payloads stay valid without anchors, so a resolution failure
91
+ // degrades to the previous behaviour instead of breaking the tool.
92
+ for (const specifier of ['../../docAnchors.js', 'proteum/docAnchors.js']) {
93
+ try {
94
+ return nodeRequire(specifier) as TDocAnchorsModule;
95
+ } catch (_error) {
96
+ continue;
97
+ }
98
+ }
99
+
100
+ return undefined;
101
+ })();
59
102
 
60
103
  const hasNodeFs = () => fs !== undefined;
61
104
  const hasNodePath = () => path !== undefined;
@@ -217,14 +260,49 @@ export const summarizeManifest = (manifest: TProteumManifest | undefined) => {
217
260
  };
218
261
  };
219
262
 
220
- const compactOwnerMatch = (match: TExplainOwnerResponse['matches'][number]) => ({
221
- kind: match.kind,
222
- label: match.label,
223
- score: match.score,
224
- scope: match.scopeLabel,
225
- origin: match.originHint,
226
- source: match.source,
227
- });
263
+ /**
264
+ * Resolve the doc anchors declared in an owner's source file.
265
+ *
266
+ * This is what makes the documentation corpus discoverable at the point an
267
+ * agent asks who owns a route: the governing feature pack, decision record and
268
+ * fix note arrive with the owner instead of costing a separate search.
269
+ */
270
+ export const readOwnerDocAnchors = (filepath?: string): TOwnerDocAnchors | undefined => {
271
+ if (!filepath || !docAnchors || fs === undefined || !fileExists(filepath)) return undefined;
272
+
273
+ let groups: TDocAnchorGroups;
274
+ try {
275
+ groups = docAnchors.collectDocAnchors(fs.readFileSync(filepath, 'utf8'));
276
+ } catch (_error) {
277
+ return undefined;
278
+ }
279
+
280
+ if (groups.entries.length === 0) return undefined;
281
+
282
+ return {
283
+ docs: groups.docs.length > 0 ? groups.docs : undefined,
284
+ adr: groups.adr.length > 0 ? groups.adr : undefined,
285
+ fix: groups.fix.length > 0 ? groups.fix : undefined,
286
+ rules:
287
+ groups.rules.length > 0
288
+ ? compactList(groups.rules, maxOwnerDocAnchorRules).map((rule) => truncateForMcp(rule))
289
+ : undefined,
290
+ };
291
+ };
292
+
293
+ const compactOwnerMatch = (match: TExplainOwnerResponse['matches'][number]) => {
294
+ const docs = readOwnerDocAnchors(match.source.filepath);
295
+
296
+ return {
297
+ kind: match.kind,
298
+ label: match.label,
299
+ score: match.score,
300
+ scope: match.scopeLabel,
301
+ origin: match.originHint,
302
+ source: match.source,
303
+ ...(docs ? { docs } : {}),
304
+ };
305
+ };
228
306
 
229
307
  const compactDiagnostic = (diagnostic: TDoctorResponse['diagnostics'][number]) => ({
230
308
  level: diagnostic.level,
@@ -22,3 +22,35 @@ export type TDevSessionStartResponse = {
22
22
  export type TDevSessionErrorResponse = {
23
23
  error: string;
24
24
  };
25
+
26
+ export const devSessionLoginPath = '/__proteum/session/login';
27
+ export const devSessionStartPath = '/__proteum/session/start';
28
+
29
+ export const normalizeDevSessionRedirectPath = (value: string): string => {
30
+ const redirect = value.trim() || '/';
31
+ if (!redirect.startsWith('/') || redirect.startsWith('//') || redirect.startsWith('/\\') || /[\r\n]/.test(redirect)) {
32
+ throw new Error('Redirect must be a local absolute path such as /dashboard.');
33
+ }
34
+
35
+ return redirect;
36
+ };
37
+
38
+ export const buildDevSessionLoginUrl = ({
39
+ baseUrl,
40
+ email,
41
+ redirect,
42
+ role,
43
+ }: {
44
+ baseUrl: string;
45
+ email: string;
46
+ redirect: string;
47
+ role?: string;
48
+ }): string => {
49
+ const params = new URLSearchParams({
50
+ email,
51
+ redirect: normalizeDevSessionRedirectPath(redirect),
52
+ });
53
+ if (role?.trim()) params.set('role', role.trim());
54
+
55
+ return `${baseUrl.replace(/\/+$/, '')}${devSessionLoginPath}?${params.toString()}`;
56
+ };
@@ -36,7 +36,6 @@ type TErrorDetails = {
36
36
  export type ServerBug = {
37
37
  // Context
38
38
  hash: string;
39
- isDuplicate: boolean;
40
39
  date: Date; // Timestamp
41
40
  channelType?: string;
42
41
  channelId?: string;
package/docAnchors.js ADDED
@@ -0,0 +1,135 @@
1
+ /*
2
+ * Shared doc-anchor contract.
3
+ *
4
+ * A doc anchor ties a source file to the durable documentation that governs it,
5
+ * so an agent editing the file sees the governing rule without first reading the
6
+ * whole documentation corpus.
7
+ *
8
+ * Supported tags, written in any leading block comment:
9
+ *
10
+ * @docs docs/features/search
11
+ * @adr ADR-0004
12
+ * @fix docs/fixes/2026-06-09-keyword-search-semantic-order.md
13
+ * @rule Composite ordering stays alias-aware. Never rewrite ORDER BY with regex.
14
+ *
15
+ * `@docs`, `@adr` and `@fix` are pointers resolved by the MCP owner payloads.
16
+ * `@rule` carries the one-line invariant inline, where an agent cannot miss it.
17
+ *
18
+ * This module is plain CommonJS with no dependencies because both the ESLint
19
+ * rules and the TypeScript MCP payload builder load it. Keep it that way so the
20
+ * lint surface and the agent-facing surface can never disagree on the format.
21
+ */
22
+
23
+ const docAnchorTags = ['docs', 'adr', 'fix', 'rule'];
24
+ const pathDocAnchorTags = ['docs', 'fix'];
25
+ const maxDocAnchorScanLength = 64 * 1024;
26
+ const minDocAnchorRuleLength = 8;
27
+
28
+ const blockCommentPattern = /\/\*[\s\S]*?\*\//g;
29
+ const commentGutterPattern = /^\s*\*+[ \t]?/;
30
+ const tagPattern = /^@([a-zA-Z][\w-]*)[ \t]*(.*)$/;
31
+
32
+ const countLinesBefore = (text, index) => {
33
+ let line = 1;
34
+ for (let cursor = 0; cursor < index; cursor += 1) {
35
+ if (text[cursor] === '\n') line += 1;
36
+ }
37
+
38
+ return line;
39
+ };
40
+
41
+ const stripCommentGutter = (line) => line.replace(commentGutterPattern, '').trim();
42
+
43
+ const isDocAnchorTag = (tag) => docAnchorTags.includes(tag);
44
+ const isPathDocAnchorTag = (tag) => pathDocAnchorTags.includes(tag);
45
+
46
+ /**
47
+ * Parse the inner text of a single block comment.
48
+ *
49
+ * `startLine` is the 1-based line the comment opens on, so reported entries
50
+ * point at the exact anchor line rather than the top of the file.
51
+ */
52
+ const parseDocAnchorComment = (commentValue, startLine = 1) => {
53
+ const entries = [];
54
+ const lines = String(commentValue === undefined || commentValue === null ? '' : commentValue).split('\n');
55
+ let current;
56
+
57
+ lines.forEach((rawLine, index) => {
58
+ const line = stripCommentGutter(rawLine);
59
+ if (line === '') {
60
+ current = undefined;
61
+ return;
62
+ }
63
+
64
+ const tagMatch = line.match(tagPattern);
65
+ if (tagMatch) {
66
+ const tag = tagMatch[1].toLowerCase();
67
+ if (!isDocAnchorTag(tag)) {
68
+ current = undefined;
69
+ return;
70
+ }
71
+
72
+ current = { tag, value: tagMatch[2].trim(), line: startLine + index };
73
+ entries.push(current);
74
+ return;
75
+ }
76
+
77
+ // A non-empty line that opens no new tag continues the previous value,
78
+ // which keeps multi-line `@rule` invariants readable in source.
79
+ if (current) current.value = `${current.value} ${line}`.trim();
80
+ });
81
+
82
+ return entries.filter((entry) => entry.value !== '');
83
+ };
84
+
85
+ const uniqueValues = (entries, tag) => {
86
+ const values = [];
87
+ entries.forEach((entry) => {
88
+ if (entry.tag !== tag || values.includes(entry.value)) return;
89
+ values.push(entry.value);
90
+ });
91
+
92
+ return values;
93
+ };
94
+
95
+ const buildDocAnchorGroups = (entries) => ({
96
+ entries,
97
+ docs: uniqueValues(entries, 'docs'),
98
+ adr: uniqueValues(entries, 'adr'),
99
+ fix: uniqueValues(entries, 'fix'),
100
+ rules: uniqueValues(entries, 'rule'),
101
+ });
102
+
103
+ const hasDocAnchorTag = (groups, tag) => groups.entries.some((entry) => entry.tag === tag);
104
+
105
+ /**
106
+ * Collect every doc anchor in raw source text.
107
+ *
108
+ * Used by the MCP owner payloads, which read a file from disk and have no AST.
109
+ * The scan is capped so enriching an owner match stays cheap on large files.
110
+ */
111
+ const collectDocAnchors = (sourceText, options) => {
112
+ const maxLength = options && options.maxLength ? options.maxLength : maxDocAnchorScanLength;
113
+ const text = String(sourceText === undefined || sourceText === null ? '' : sourceText).slice(0, maxLength);
114
+ const entries = [];
115
+
116
+ blockCommentPattern.lastIndex = 0;
117
+ let match = blockCommentPattern.exec(text);
118
+ while (match !== null) {
119
+ const body = match[0].slice(2, -2);
120
+ entries.push(...parseDocAnchorComment(body, countLinesBefore(text, match.index)));
121
+ match = blockCommentPattern.exec(text);
122
+ }
123
+
124
+ return buildDocAnchorGroups(entries);
125
+ };
126
+
127
+ module.exports = {
128
+ buildDocAnchorGroups,
129
+ collectDocAnchors,
130
+ docAnchorTags,
131
+ hasDocAnchorTag,
132
+ isPathDocAnchorTag,
133
+ minDocAnchorRuleLength,
134
+ parseDocAnchorComment,
135
+ };
@@ -96,11 +96,11 @@ Standard triggered reads:
96
96
  - Worktree Preflight (`cwd` inside `/.codex/worktrees/`, newly created Proteum worktree, or before editing in a Codex worktree): root contract fallback, then `npx proteum worktree init --source <source-app-root>` or the returned `--refresh` command, `npx proteum runtime status`, and tracked `npx proteum dev` for runtime-visible work.
97
97
  - Git lifecycle (`commit`, `and commit`, `stage`, `push`, `PR`, pull request): root contract fallback.
98
98
  - Before git writes after a bug fix, behavior change, decision change, or docs-relevant production change: `DOCUMENTATION.md`.
99
- - Before finishing production code changes: root contract fallback, `DOCUMENTATION.md`, `CODING_STYLE.md`, and touched area `AGENTS.md`.
99
+ - Before finishing production code changes: root contract fallback, `DOCUMENTATION.md`, `CODING_STYLE.md`, and touched area `AGENTS.md`, then the `CODING_STYLE.md` self-check on the final diff; a non-obvious decision without a why-comment is a defect.
100
100
  - Runtime-visible, request-time, router, SSR, browser, or controller behavior: root contract fallback plus `diagnostics.md`.
101
101
  - Bug fixes, regressions, incidents, broken public routes, auth/OAuth failures, integration failures, or production behavior fixes: `DOCUMENTATION.md`.
102
102
  - Non-trivial feature, product, business-rule, UX, copy, or docs changes: `DOCUMENTATION.md`.
103
- - Implementation edits: `CODING_STYLE.md` plus the matching area file from the routing table.
103
+ - Implementation edits: `CODING_STYLE.md` plus the matching area file from the routing table; record non-obvious decisions, workarounds, and constraints as why-comments while editing.
104
104
 
105
105
  `workflow_start`, `orient`, `route_candidates`, and MCP `instructions_resolve` should promote obvious triggered files into selected instruction previews; ambiguous conditional reads can remain in `readWhen`.
106
106
 
package/eslint.js CHANGED
@@ -1,8 +1,19 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
1
3
  const tseslint = require('typescript-eslint');
2
4
  const reactPlugin = require('eslint-plugin-react');
3
5
  const reactHooksPlugin = require('eslint-plugin-react-hooks');
4
6
  const jsxA11yPlugin = require('eslint-plugin-jsx-a11y');
5
7
 
8
+ const {
9
+ buildDocAnchorGroups,
10
+ docAnchorTags,
11
+ hasDocAnchorTag,
12
+ isPathDocAnchorTag,
13
+ minDocAnchorRuleLength,
14
+ parseDocAnchorComment,
15
+ } = require('./docAnchors.js');
16
+
6
17
  const defaultIgnores = [
7
18
  '**/node_modules/**',
8
19
  '**/bin/**',
@@ -307,7 +318,250 @@ const createNoAppImportRule = () => ({
307
318
  },
308
319
  });
309
320
 
310
- const createProteumEslintConfig = ({ ignores = [] } = {}) => [
321
+ // Error routes are deliberately absent: a `_messages/404` page renders a status
322
+ // message and carries no feature-specific rule, so requiring a feature pack for
323
+ // one would manufacture documentation to satisfy the linter. An error page that
324
+ // does carry a real rule can still add an anchor, and `valid-doc-anchor` keeps
325
+ // checking it.
326
+ const defaultDocAnchorDefinitions = [
327
+ 'defineController',
328
+ 'definePageRoute',
329
+ 'defineServerRoute',
330
+ 'defineServerRoutes',
331
+ ];
332
+
333
+ const docsRootCache = new Map();
334
+ const directoryEntriesCache = new Map();
335
+
336
+ const getSourceCode = (context) => context.sourceCode || context.getSourceCode?.();
337
+
338
+ const getContextCwd = (context) => context.cwd || context.getCwd?.() || process.cwd();
339
+
340
+ const pathExists = (candidate) => {
341
+ try {
342
+ return fs.existsSync(candidate);
343
+ } catch (_error) {
344
+ return false;
345
+ }
346
+ };
347
+
348
+ const readDirectoryEntries = (directory) => {
349
+ if (directoryEntriesCache.has(directory)) return directoryEntriesCache.get(directory);
350
+
351
+ let entries = [];
352
+ try {
353
+ entries = fs.readdirSync(directory);
354
+ } catch (_error) {
355
+ entries = [];
356
+ }
357
+
358
+ directoryEntriesCache.set(directory, entries);
359
+ return entries;
360
+ };
361
+
362
+ /**
363
+ * Collect every ancestor of a linted file that holds a `docs/` directory,
364
+ * nearest first.
365
+ *
366
+ * All of them are candidates, not just the nearest: a monorepo app commonly has
367
+ * its own `apps/<app>/docs/` alongside the repository-level corpus, and stopping
368
+ * at the first match would make every anchor aimed at the shared corpus fail to
369
+ * resolve.
370
+ */
371
+ const findDocsRoots = (startDirectory) => {
372
+ if (docsRootCache.has(startDirectory)) return docsRootCache.get(startDirectory);
373
+
374
+ const resolved = [];
375
+ let current = startDirectory;
376
+ while (current) {
377
+ if (pathExists(path.join(current, 'docs'))) resolved.push(current);
378
+
379
+ const parent = path.dirname(current);
380
+ if (parent === current) break;
381
+ current = parent;
382
+ }
383
+
384
+ docsRootCache.set(startDirectory, resolved);
385
+ return resolved;
386
+ };
387
+
388
+ const resolveAnchorRoots = (context) => {
389
+ const filename = context.filename || context.getFilename?.() || '';
390
+ const fileDirectory = filename ? path.dirname(filename) : undefined;
391
+ const cwd = getContextCwd(context);
392
+ const roots = [];
393
+
394
+ const addRoot = (root) => {
395
+ if (root && !roots.includes(root)) roots.push(root);
396
+ };
397
+
398
+ if (fileDirectory) findDocsRoots(fileDirectory).forEach(addRoot);
399
+ addRoot(cwd);
400
+ addRoot(fileDirectory);
401
+
402
+ return roots;
403
+ };
404
+
405
+ const resolvePathAnchor = (value, roots) => {
406
+ if (path.isAbsolute(value)) return pathExists(value);
407
+
408
+ return roots.some((root) => pathExists(path.resolve(root, value)));
409
+ };
410
+
411
+ const resolveAdrAnchor = (value, roots) => {
412
+ const normalized = value.trim().toLowerCase();
413
+ if (!normalized) return false;
414
+
415
+ const decisionDirectories = roots
416
+ .map((root) => path.join(root, 'docs', 'decisions'))
417
+ .filter((directory) => pathExists(directory));
418
+
419
+ // Projects without a decisions corpus never fail this check, so the rule
420
+ // stays silent instead of inventing a convention the app has not adopted.
421
+ if (decisionDirectories.length === 0) return true;
422
+
423
+ return decisionDirectories.some((directory) =>
424
+ readDirectoryEntries(directory).some((entry) => entry.toLowerCase().startsWith(normalized)),
425
+ );
426
+ };
427
+
428
+ const collectFileDocAnchors = (context) => {
429
+ const sourceCode = getSourceCode(context);
430
+ if (!sourceCode) return buildDocAnchorGroups([]);
431
+
432
+ const entries = [];
433
+ sourceCode.getAllComments().forEach((comment) => {
434
+ if (comment.type !== 'Block') return;
435
+ entries.push(...parseDocAnchorComment(comment.value, comment.loc?.start?.line || 1));
436
+ });
437
+
438
+ return buildDocAnchorGroups(entries);
439
+ };
440
+
441
+ const unwrapExpression = (node) => {
442
+ let current = node;
443
+ while (
444
+ current &&
445
+ (current.type === 'TSAsExpression' ||
446
+ current.type === 'TSSatisfiesExpression' ||
447
+ current.type === 'TSNonNullExpression')
448
+ ) {
449
+ current = current.expression;
450
+ }
451
+
452
+ return current;
453
+ };
454
+
455
+ const getDefinitionCalleeName = (node, definitions) => {
456
+ const expression = unwrapExpression(node);
457
+ if (expression?.type !== 'CallExpression') return null;
458
+
459
+ const name = getCalleePropertyName(expression.callee);
460
+ return name && definitions.includes(name) ? name : null;
461
+ };
462
+
463
+ const createRequireDocAnchorRule = () => ({
464
+ meta: {
465
+ type: 'suggestion',
466
+ docs: {
467
+ description: 'Require Proteum definition files to anchor the documentation that governs them.',
468
+ },
469
+ messages: {
470
+ missingAnchor:
471
+ '`{{definition}}` files must carry a doc anchor. Add a leading block comment with `@docs <path to the feature pack>`, plus `@rule <one-line invariant>` when a fix or decision constrains this file.',
472
+ missingTag:
473
+ '`{{definition}}` files must carry a `@{{tag}}` doc anchor in a leading block comment.',
474
+ },
475
+ schema: [
476
+ {
477
+ type: 'object',
478
+ properties: {
479
+ definitions: { type: 'array', items: { type: 'string' } },
480
+ requiredTags: { type: 'array', items: { enum: docAnchorTags } },
481
+ },
482
+ additionalProperties: false,
483
+ },
484
+ ],
485
+ },
486
+ create(context) {
487
+ const options = context.options?.[0] || {};
488
+ const definitions = options.definitions || defaultDocAnchorDefinitions;
489
+ const requiredTags = options.requiredTags || ['docs'];
490
+
491
+ return {
492
+ ExportDefaultDeclaration(node) {
493
+ const definition = getDefinitionCalleeName(node.declaration, definitions);
494
+ if (!definition) return;
495
+
496
+ const anchors = collectFileDocAnchors(context);
497
+ if (anchors.entries.length === 0) {
498
+ context.report({ node, messageId: 'missingAnchor', data: { definition } });
499
+ return;
500
+ }
501
+
502
+ const missingTag = requiredTags.find((tag) => !hasDocAnchorTag(anchors, tag));
503
+ if (missingTag) {
504
+ context.report({ node, messageId: 'missingTag', data: { definition, tag: missingTag } });
505
+ }
506
+ },
507
+ };
508
+ },
509
+ });
510
+
511
+ const createValidDocAnchorRule = () => ({
512
+ meta: {
513
+ type: 'problem',
514
+ docs: {
515
+ description: 'Require doc anchors to point at documentation that still exists.',
516
+ },
517
+ messages: {
518
+ unresolvedPath:
519
+ 'Doc anchor `@{{tag}} {{value}}` does not resolve to a file or directory. Update the anchor to the current documentation path, or remove it.',
520
+ unresolvedAdr:
521
+ 'Doc anchor `@adr {{value}}` matches no decision record under `docs/decisions`. Use the current ADR identifier.',
522
+ emptyRule:
523
+ 'Doc anchor `@rule` must state the invariant in full so an agent editing this file can apply it without opening the linked document.',
524
+ },
525
+ schema: [],
526
+ },
527
+ create(context) {
528
+ return {
529
+ 'Program:exit'() {
530
+ const anchors = collectFileDocAnchors(context);
531
+ if (anchors.entries.length === 0) return;
532
+
533
+ const roots = resolveAnchorRoots(context);
534
+ anchors.entries.forEach((entry) => {
535
+ const loc = { line: entry.line, column: 0 };
536
+
537
+ if (entry.tag === 'rule') {
538
+ if (entry.value.length < minDocAnchorRuleLength) {
539
+ context.report({ loc, messageId: 'emptyRule' });
540
+ }
541
+ return;
542
+ }
543
+
544
+ if (isPathDocAnchorTag(entry.tag)) {
545
+ if (!resolvePathAnchor(entry.value, roots)) {
546
+ context.report({
547
+ loc,
548
+ messageId: 'unresolvedPath',
549
+ data: { tag: entry.tag, value: entry.value },
550
+ });
551
+ }
552
+ return;
553
+ }
554
+
555
+ if (entry.tag === 'adr' && !resolveAdrAnchor(entry.value, roots)) {
556
+ context.report({ loc, messageId: 'unresolvedAdr', data: { value: entry.value } });
557
+ }
558
+ });
559
+ },
560
+ };
561
+ },
562
+ });
563
+
564
+ const createProteumEslintConfig = ({ docAnchors = 'warn', ignores = [] } = {}) => [
311
565
  {
312
566
  ignores: [...defaultIgnores, ...ignores],
313
567
  },
@@ -334,6 +588,8 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
334
588
  rules: {
335
589
  'no-app-import': createNoAppImportRule(),
336
590
  'no-swallowed-caught-error': createSwallowedErrorRule(),
591
+ 'require-doc-anchor': createRequireDocAnchorRule(),
592
+ 'valid-doc-anchor': createValidDocAnchorRule(),
337
593
  },
338
594
  },
339
595
  react: reactPlugin,
@@ -344,6 +600,13 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
344
600
  '@typescript-eslint/no-explicit-any': 'error',
345
601
  'proteum/no-app-import': 'error',
346
602
  'proteum/no-swallowed-caught-error': 'error',
603
+ // Missing anchors warn by default so adopting apps see the backlog
604
+ // without a failing build; pass `docAnchors: 'error'` once backfilled.
605
+ 'proteum/require-doc-anchor': docAnchors,
606
+ // A stale anchor is always an error: it only fires on files that
607
+ // already opted in, and a pointer to a deleted document is worse
608
+ // than no pointer at all.
609
+ 'proteum/valid-doc-anchor': docAnchors === 'off' ? 'off' : 'error',
347
610
  'no-restricted-syntax': [
348
611
  'error',
349
612
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.5.9",
4
+ "version": "2.5.11",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -6,7 +6,6 @@
6
6
  import { serialize } from 'v8';
7
7
  import { formatWithOptions } from 'util';
8
8
  import md5 from 'md5';
9
- import dayjs from 'dayjs';
10
9
  import stringify from 'fast-safe-stringify';
11
10
 
12
11
  // Npm
@@ -178,7 +177,6 @@ export default class Console {
178
177
  public logger!: Logger<ILogObj>;
179
178
  // Buffers
180
179
  public logs: TJsonLog[] = [];
181
- private reported: { [hash: string]: { times: number; last: Date } } = {};
182
180
 
183
181
  /*----------------------------------
184
182
  - LIFECYCLE
@@ -371,24 +369,9 @@ export default class Console {
371
369
  // Genertae unique error hash
372
370
  const hash = md5(inspection.stacktraces[0]);
373
371
 
374
- // Don't send the same error twice in a row (avoid email spamming)
375
- const lastReport = this.reported[hash];
376
- let isDuplicate = false;
377
- if (lastReport === undefined) {
378
- this.reported[hash] = { times: 0, last: new Date() };
379
-
380
- // If error older than 1 day
381
- } else if (dayjs(now).diff(dayjs(lastReport.last), 'day') > 1) {
382
- lastReport.times++;
383
- lastReport.last = now;
384
- } else {
385
- isDuplicate = true;
386
- }
387
-
388
372
  const bugReport: ServerBug = {
389
373
  // Context
390
374
  hash: hash,
391
- isDuplicate,
392
375
  date: now,
393
376
  channelType,
394
377
  channelId,