proteum 2.5.10 → 2.5.12

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.
@@ -1,17 +1,53 @@
1
1
  const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
2
4
  const { Linter } = require('eslint');
3
5
 
4
6
  const { createProteumEslintConfig } = require('../eslint.js');
5
7
 
6
- const lint = (code, filename = 'client/example.tsx') => {
8
+ const lint = (code, filename = 'client/example.tsx', configOptions) => {
7
9
  const linter = new Linter({ configType: 'flat' });
8
- return linter.verify(code, createProteumEslintConfig(), {
10
+ return linter.verify(code, createProteumEslintConfig(configOptions), {
9
11
  filename,
10
12
  });
11
13
  };
12
14
 
13
15
  const swallowedErrorRuleId = 'proteum/no-swallowed-caught-error';
14
16
  const noAppImportRuleId = 'proteum/no-app-import';
17
+ const requireDocAnchorRuleId = 'proteum/require-doc-anchor';
18
+ const validDocAnchorRuleId = 'proteum/valid-doc-anchor';
19
+
20
+ const messagesFor = (messages, ruleId) => messages.filter((message) => message.ruleId === ruleId);
21
+
22
+ // The fixture lives inside the repository `.temp` directory rather than the OS
23
+ // temp directory: on macOS the latter sits under `/var/folders`, which the
24
+ // shared `**/var/**` ignore would exclude from linting entirely.
25
+ const docProjectParent = path.resolve(__dirname, '..', '.temp');
26
+ const docProjectRoots = [];
27
+
28
+ afterAll(() => {
29
+ docProjectRoots.forEach((root) => fs.rmSync(root, { force: true, recursive: true }));
30
+ });
31
+
32
+ /**
33
+ * Build a throwaway project whose documentation corpus really exists on disk,
34
+ * because the anchor rules resolve their paths against the filesystem.
35
+ */
36
+ const createDocProject = () => {
37
+ fs.mkdirSync(docProjectParent, { recursive: true });
38
+ const root = fs.mkdtempSync(path.join(docProjectParent, 'proteum-doc-anchor-'));
39
+ docProjectRoots.push(root);
40
+
41
+ fs.mkdirSync(path.join(root, 'docs', 'features', 'search'), { recursive: true });
42
+ fs.writeFileSync(path.join(root, 'docs', 'features', 'search', 'README.md'), '# Search\n');
43
+ fs.mkdirSync(path.join(root, 'docs', 'decisions'), { recursive: true });
44
+ fs.writeFileSync(path.join(root, 'docs', 'decisions', 'ADR-0004-page-query-contracts.md'), '# ADR-0004\n');
45
+ fs.mkdirSync(path.join(root, 'docs', 'fixes'), { recursive: true });
46
+ fs.writeFileSync(path.join(root, 'docs', 'fixes', '2026-06-09-keyword-order.md'), '# Fix\n');
47
+ fs.mkdirSync(path.join(root, 'client', 'pages'), { recursive: true });
48
+
49
+ return { pageFile: path.join(root, 'client', 'pages', 'browse.tsx'), root };
50
+ };
15
51
 
16
52
  test('proteum lint rejects contextual @app imports', () => {
17
53
  const messages = lint(`
@@ -384,3 +420,283 @@ test('proteum lint allows direct reject promise catch handlers', () => {
384
420
 
385
421
  assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
386
422
  });
423
+
424
+ test('proteum lint requires a doc anchor on definition files', () => {
425
+ const { pageFile } = createDocProject();
426
+ const messages = lint(`export default definePageRoute({ path: '/browse' });`, pageFile);
427
+
428
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 1);
429
+ });
430
+
431
+ test('proteum lint accepts a definition file that anchors its feature pack', () => {
432
+ const { pageFile } = createDocProject();
433
+ const messages = lint(
434
+ `
435
+ /**
436
+ * @docs docs/features/search
437
+ */
438
+ export default definePageRoute({ path: '/browse' });
439
+ `,
440
+ pageFile,
441
+ );
442
+
443
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
444
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 0);
445
+ });
446
+
447
+ test('proteum lint requires a doc anchor on every Proteum definition kind', () => {
448
+ const { pageFile } = createDocProject();
449
+
450
+ for (const definition of [
451
+ 'defineController',
452
+ 'definePageRoute',
453
+ 'defineServerRoute',
454
+ 'defineServerRoutes',
455
+ ]) {
456
+ const messages = lint(`export default ${definition}({ path: '/browse' });`, pageFile);
457
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 1, definition);
458
+ }
459
+ });
460
+
461
+ test('proteum lint requires a doc anchor on exported service classes', () => {
462
+ const { root } = createDocProject();
463
+ const serviceFile = path.join(root, 'server', 'services', 'Domains', 'search', 'index.ts');
464
+
465
+ const missing = lint(
466
+ `export default class DomainsSearchService extends Service<Config, {}, Application, object> {}`,
467
+ serviceFile,
468
+ );
469
+ assert.equal(messagesFor(missing, requireDocAnchorRuleId).length, 1);
470
+
471
+ const anchored = lint(
472
+ `
473
+ /**
474
+ * @docs docs/features/search
475
+ */
476
+ export default class DomainsSearchService extends Service<Config, {}, Application, object> {}
477
+ `,
478
+ serviceFile,
479
+ );
480
+ assert.equal(messagesFor(anchored, requireDocAnchorRuleId).length, 0);
481
+ });
482
+
483
+ test('proteum lint covers app-specific service base classes and named exports', () => {
484
+ const { root } = createDocProject();
485
+ const messages = lint(
486
+ `export class AuthManagement extends UsersManagementService<TUser, AuthApplication, TJwtSession> {}`,
487
+ path.join(root, 'server', 'services', 'Users', 'Auth', 'index.ts'),
488
+ );
489
+
490
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 1);
491
+ });
492
+
493
+ test('proteum lint leaves non-service classes alone', () => {
494
+ const { root } = createDocProject();
495
+ const messages = lint(
496
+ `export default class DomainCard extends React.Component {}`,
497
+ path.join(root, 'client', 'components', 'DomainCard.tsx'),
498
+ );
499
+
500
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
501
+ });
502
+
503
+ test('proteum lint reports a service class only once per file', () => {
504
+ const { root } = createDocProject();
505
+ const messages = lint(
506
+ `
507
+ export class FirstService extends Service {}
508
+ export default class SecondService extends Service {}
509
+ `,
510
+ path.join(root, 'server', 'services', 'Two', 'index.ts'),
511
+ );
512
+
513
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 1);
514
+ });
515
+
516
+ test('proteum lint opts extra files in through include globs', () => {
517
+ const { root } = createDocProject();
518
+ const paywallFile = path.join(root, 'client', 'components', 'paywall', 'PaywallModal', 'index.tsx');
519
+ const iconFile = path.join(root, 'client', 'components', 'Icon.tsx');
520
+ const source = `const Modal = () => null;\nexport default Modal;\n`;
521
+ const options = { docAnchors: 'warn', includeDocAnchors: ['client/components/paywall/**'] };
522
+
523
+ assert.equal(messagesFor(lint(source, paywallFile, options), requireDocAnchorRuleId).length, 1);
524
+ assert.equal(messagesFor(lint(source, iconFile, options), requireDocAnchorRuleId).length, 0);
525
+ });
526
+
527
+ test('proteum lint accepts an included file once it carries an anchor', () => {
528
+ const { root } = createDocProject();
529
+ const messages = lint(
530
+ `
531
+ /**
532
+ * @docs docs/features/search
533
+ */
534
+ const Modal = () => null;
535
+ export default Modal;
536
+ `,
537
+ path.join(root, 'client', 'components', 'paywall', 'PaywallModal', 'index.tsx'),
538
+ { docAnchors: 'warn', includeDocAnchors: ['client/components/paywall/**'] },
539
+ );
540
+
541
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
542
+ });
543
+
544
+ test('proteum lint does not require a doc anchor on error routes', () => {
545
+ const { root } = createDocProject();
546
+ const messages = lint(
547
+ `export default defineErrorRoute({ code: 404 });`,
548
+ path.join(root, 'client', 'pages', '_messages', '404.tsx'),
549
+ );
550
+
551
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
552
+ });
553
+
554
+ test('proteum lint ignores files that export no Proteum definition', () => {
555
+ const { pageFile } = createDocProject();
556
+ const messages = lint(`export default { path: '/browse' };`, pageFile);
557
+
558
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
559
+ });
560
+
561
+ test('proteum lint reports a definition file whose anchors omit the feature pack', () => {
562
+ const { pageFile } = createDocProject();
563
+ const messages = lint(
564
+ `
565
+ /**
566
+ * @rule Browse rows never expose raw score values.
567
+ */
568
+ export default definePageRoute({ path: '/browse' });
569
+ `,
570
+ pageFile,
571
+ );
572
+
573
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 1);
574
+ });
575
+
576
+ test('proteum lint resolves anchors against the repo corpus when the app has its own docs directory', () => {
577
+ const { root } = createDocProject();
578
+
579
+ // Mirrors the monorepo layout: apps/<app>/docs/ sits between the source file
580
+ // and the repository-level corpus that the anchor actually points at.
581
+ const appRoot = path.join(root, 'apps', 'website');
582
+ fs.mkdirSync(path.join(appRoot, 'docs', 'fixes'), { recursive: true });
583
+ fs.mkdirSync(path.join(appRoot, 'client', 'pages'), { recursive: true });
584
+
585
+ const messages = lint(
586
+ `
587
+ /**
588
+ * @docs docs/features/search
589
+ */
590
+ export default definePageRoute({ path: '/browse' });
591
+ `,
592
+ path.join(appRoot, 'client', 'pages', 'browse.tsx'),
593
+ );
594
+
595
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 0);
596
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
597
+ });
598
+
599
+ test('proteum lint rejects a doc anchor pointing at a missing document', () => {
600
+ const { pageFile } = createDocProject();
601
+ const messages = lint(
602
+ `
603
+ /**
604
+ * @docs docs/features/deleted-feature
605
+ */
606
+ export default definePageRoute({ path: '/browse' });
607
+ `,
608
+ pageFile,
609
+ );
610
+
611
+ const reported = messagesFor(messages, validDocAnchorRuleId);
612
+ assert.equal(reported.length, 1);
613
+ assert.equal(/docs\/features\/deleted-feature/.test(reported[0].message), true);
614
+ });
615
+
616
+ test('proteum lint resolves fix and decision anchors against the documentation corpus', () => {
617
+ const { pageFile } = createDocProject();
618
+ const messages = lint(
619
+ `
620
+ /**
621
+ * @docs docs/features/search
622
+ * @adr ADR-0004
623
+ * @fix docs/fixes/2026-06-09-keyword-order.md
624
+ * @rule Composite ordering stays alias-aware.
625
+ */
626
+ export default definePageRoute({ path: '/browse' });
627
+ `,
628
+ pageFile,
629
+ );
630
+
631
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 0);
632
+ });
633
+
634
+ test('proteum lint rejects a decision anchor that matches no decision record', () => {
635
+ const { pageFile } = createDocProject();
636
+ const messages = lint(
637
+ `
638
+ /**
639
+ * @docs docs/features/search
640
+ * @adr ADR-9999
641
+ */
642
+ export default definePageRoute({ path: '/browse' });
643
+ `,
644
+ pageFile,
645
+ );
646
+
647
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 1);
648
+ });
649
+
650
+ test('proteum lint rejects a rule anchor that states no invariant', () => {
651
+ const { pageFile } = createDocProject();
652
+ const messages = lint(
653
+ `
654
+ /**
655
+ * @docs docs/features/search
656
+ * @rule todo
657
+ */
658
+ export default definePageRoute({ path: '/browse' });
659
+ `,
660
+ pageFile,
661
+ );
662
+
663
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 1);
664
+ });
665
+
666
+ test('proteum lint validates anchors on files that export no definition', () => {
667
+ const { root } = createDocProject();
668
+ const messages = lint(
669
+ `
670
+ /**
671
+ * @docs docs/features/deleted-feature
672
+ */
673
+ export const helper = () => null;
674
+ `,
675
+ path.join(root, 'server', 'services', 'search.ts'),
676
+ );
677
+
678
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 1);
679
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
680
+ });
681
+
682
+ test('proteum lint escalates and disables doc anchor rules through config options', () => {
683
+ const { pageFile } = createDocProject();
684
+ const source = `
685
+ /**
686
+ * @docs docs/features/deleted-feature
687
+ */
688
+ export default definePageRoute({ path: '/browse' });
689
+ `;
690
+
691
+ const warned = lint(`export default definePageRoute({ path: '/browse' });`, pageFile);
692
+ assert.equal(messagesFor(warned, requireDocAnchorRuleId)[0].severity, 1);
693
+
694
+ const escalated = lint(`export default definePageRoute({ path: '/browse' });`, pageFile, {
695
+ docAnchors: 'error',
696
+ });
697
+ assert.equal(messagesFor(escalated, requireDocAnchorRuleId)[0].severity, 2);
698
+
699
+ const disabled = lint(source, pageFile, { docAnchors: 'off' });
700
+ assert.equal(messagesFor(disabled, requireDocAnchorRuleId).length, 0);
701
+ assert.equal(messagesFor(disabled, validDocAnchorRuleId).length, 0);
702
+ });
@@ -18,6 +18,7 @@ const {
18
18
  compactRouteCandidatesResponse,
19
19
  compactTraceResponse,
20
20
  compactWorkflowStartResponse,
21
+ readOwnerDocAnchors,
21
22
  resolveInstructionRouting,
22
23
  } = require('../common/dev/mcpPayloads.ts');
23
24
  const { createProteumMcpServer } = require('../common/dev/mcpServer.ts');
@@ -241,6 +242,114 @@ test('instruction routing promotes triggered full instruction files', () => {
241
242
  );
242
243
  });
243
244
 
245
+ test('owner doc anchors resolve the documentation that governs a source file', () => {
246
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-doc-anchor-'));
247
+ const anchoredFile = path.join(appRoot, 'client/pages/browse.tsx');
248
+ const plainFile = path.join(appRoot, 'client/pages/plain.tsx');
249
+
250
+ writeFile(
251
+ anchoredFile,
252
+ [
253
+ '/**',
254
+ ' * @docs docs/features/search',
255
+ ' * @adr ADR-0004',
256
+ ' * @fix docs/fixes/2026-06-09-keyword-order.md',
257
+ ' * @rule Composite ordering stays alias-aware.',
258
+ ' */',
259
+ "export default definePageRoute({ path: '/browse' });",
260
+ ].join('\n'),
261
+ );
262
+ writeFile(plainFile, "export default definePageRoute({ path: '/plain' });\n");
263
+
264
+ const anchors = readOwnerDocAnchors(anchoredFile);
265
+
266
+ assert.deepEqual(anchors.docs, ['docs/features/search']);
267
+ assert.deepEqual(anchors.adr, ['ADR-0004']);
268
+ assert.deepEqual(anchors.fix, ['docs/fixes/2026-06-09-keyword-order.md']);
269
+ assert.deepEqual(anchors.rules, ['Composite ordering stays alias-aware.']);
270
+
271
+ assert.equal(readOwnerDocAnchors(plainFile), undefined);
272
+ assert.equal(readOwnerDocAnchors(path.join(appRoot, 'missing.tsx')), undefined);
273
+ assert.equal(readOwnerDocAnchors(undefined), undefined);
274
+ });
275
+
276
+ test('owner payloads carry the doc anchors declared by the owning file', () => {
277
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-owner-docs-'));
278
+ const pageFile = path.join(appRoot, 'client/pages/domains.tsx');
279
+
280
+ writeFile(path.join(appRoot, 'AGENTS.md'), '# App Agents\n\n- root\n');
281
+ writeFile(
282
+ pageFile,
283
+ [
284
+ '/**',
285
+ ' * @docs docs/features/product-domain-listings',
286
+ ' * @rule Public rows never expose raw score values.',
287
+ ' */',
288
+ 'export default function Domains() { return null; }',
289
+ ].join('\n'),
290
+ );
291
+
292
+ const manifest = {
293
+ version: 10,
294
+ app: {
295
+ root: appRoot,
296
+ coreRoot,
297
+ identityFilepath: path.join(appRoot, 'identity.config.ts'),
298
+ setupFilepath: path.join(appRoot, 'proteum.config.ts'),
299
+ identity: { name: 'Owner Docs App', identifier: 'OwnerDocsApp', description: '' },
300
+ setup: {},
301
+ },
302
+ conventions: { routeOptionKeys: [], reservedRouteOptionKeys: [] },
303
+ env: {
304
+ source: 'test',
305
+ loadedVariableKeys: [],
306
+ requiredVariables: [],
307
+ resolved: {
308
+ name: 'test',
309
+ profile: 'dev',
310
+ routerPort: 3105,
311
+ routerCurrentDomain: 'localhost',
312
+ routerInternalUrl: 'http://localhost:3105',
313
+ },
314
+ },
315
+ connectedProjects: [],
316
+ services: { app: [], routerPlugins: [] },
317
+ controllers: [],
318
+ commands: [],
319
+ routes: { client: [], server: [] },
320
+ layouts: [],
321
+ diagnostics: [],
322
+ };
323
+ const doctor = { summary: { errors: 0, warnings: 0, strictFailed: false }, diagnostics: [] };
324
+ const payload = compactWorkflowStartResponse({
325
+ contracts: doctor,
326
+ doctor,
327
+ manifest,
328
+ owner: {
329
+ matches: [
330
+ {
331
+ details: [],
332
+ kind: 'route',
333
+ label: '/domains',
334
+ matchedOn: ['path'],
335
+ originHint: 'manifest',
336
+ scopeLabel: 'local',
337
+ score: 100,
338
+ source: { filepath: pageFile, line: 1, column: 1 },
339
+ },
340
+ ],
341
+ normalizedQuery: '/domains',
342
+ query: '/domains',
343
+ },
344
+ route: '/domains',
345
+ runtime: { publicUrl: 'http://localhost:3105', mcpUrl: 'http://localhost:3105/__proteum/mcp' },
346
+ task: 'read-only runtime health pass',
347
+ });
348
+
349
+ assert.deepEqual(payload.data.owner.top.docs.docs, ['docs/features/product-domain-listings']);
350
+ assert.deepEqual(payload.data.owner.top.docs.rules, ['Public rows never expose raw score values.']);
351
+ });
352
+
244
353
  test('workflow start payload combines compact runtime, instructions, owner, and duplicate guidance', () => {
245
354
  const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-workflow-app-'));
246
355
  const pageFile = path.join(appRoot, 'client/pages/domains.tsx');
@@ -46,7 +46,10 @@ test('changed verification planner runs related tests for source files', () => {
46
46
  changedFiles: ['packages/auth/src/session.ts'],
47
47
  });
48
48
 
49
- assert.deepEqual(planCommands(plan), ["npx vitest related 'packages/auth/src/session.ts'"]);
49
+ assert.deepEqual(planCommands(plan), [
50
+ "npx vitest related 'packages/auth/src/session.ts'",
51
+ 'npx proteum docs check',
52
+ ]);
50
53
  assert.deepEqual(plan.selectedChecks[0].matchedFiles, ['packages/auth/src/session.ts']);
51
54
  });
52
55
 
@@ -115,7 +118,9 @@ test('changed verification planner skips tests for docs-only changes', () => {
115
118
  changedFiles: ['docs/testing.md'],
116
119
  });
117
120
 
118
- assert.deepEqual(plan.selectedChecks, []);
121
+ // Test suites are skipped, but the doc-anchor check still runs: moving or
122
+ // renaming a document is precisely what leaves an anchor pointing nowhere.
123
+ assert.deepEqual(planIds(plan), ['builtin:doc-anchors']);
119
124
  assert.equal(plan.docsOnly, true);
120
125
  assert.deepEqual(plan.skippedChecks.map((check) => check.id), ['builtin:docs-only']);
121
126
  });
@@ -195,6 +200,49 @@ test('verify changed CLI JSON output keeps the planner and execution shape stabl
195
200
  assert.ok(Array.isArray(output.skippedChecks));
196
201
  assert.ok(Array.isArray(output.executions));
197
202
  assert.equal(typeof output.result.ok, 'boolean');
198
- assert.equal(output.result.selectedChecks, 0);
203
+ // A docs-only change still skips the test suites, but it does run the
204
+ // doc-anchor check: renaming a document is exactly what orphans an anchor.
205
+ assert.equal(output.result.selectedChecks, 1);
206
+ assert.deepEqual(
207
+ output.selectedChecks.map((check) => check.id),
208
+ ['builtin:doc-anchors'],
209
+ );
199
210
  assert.equal(output.result.failedChecks, 0);
200
211
  });
212
+
213
+ test('changed verification planner checks doc anchors when documentation moves', () => {
214
+ const root = createRoot();
215
+ writeFile(root, 'docs/features/search/README.md', '# Search\n');
216
+
217
+ const plan = buildChangedVerificationPlan({
218
+ cwd: root,
219
+ changedFiles: ['docs/features/search/README.md'],
220
+ });
221
+
222
+ assert.deepEqual(planIds(plan), ['builtin:doc-anchors']);
223
+ assert.deepEqual(planCommands(plan), ['npx proteum docs check']);
224
+ });
225
+
226
+ test('changed verification planner checks doc anchors when a source file changes', () => {
227
+ const root = createRoot();
228
+ writeFile(root, 'apps/product/server/controllers/Domains/search.ts', 'export default {};\n');
229
+
230
+ const plan = buildChangedVerificationPlan({
231
+ cwd: root,
232
+ changedFiles: ['apps/product/server/controllers/Domains/search.ts'],
233
+ });
234
+
235
+ assert.ok(planIds(plan).includes('builtin:doc-anchors'));
236
+ });
237
+
238
+ test('changed verification planner leaves doc anchors alone for unrelated changes', () => {
239
+ const root = createRoot();
240
+ writeFile(root, 'README.md', '# Root\n');
241
+
242
+ const plan = buildChangedVerificationPlan({
243
+ cwd: root,
244
+ changedFiles: ['README.md'],
245
+ });
246
+
247
+ assert.equal(planIds(plan).includes('builtin:doc-anchors'), false);
248
+ });