proteum 2.5.10 → 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.
@@ -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,200 @@ 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 does not require a doc anchor on error routes', () => {
462
+ const { root } = createDocProject();
463
+ const messages = lint(
464
+ `export default defineErrorRoute({ code: 404 });`,
465
+ path.join(root, 'client', 'pages', '_messages', '404.tsx'),
466
+ );
467
+
468
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
469
+ });
470
+
471
+ test('proteum lint ignores files that export no Proteum definition', () => {
472
+ const { pageFile } = createDocProject();
473
+ const messages = lint(`export default { path: '/browse' };`, pageFile);
474
+
475
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
476
+ });
477
+
478
+ test('proteum lint reports a definition file whose anchors omit the feature pack', () => {
479
+ const { pageFile } = createDocProject();
480
+ const messages = lint(
481
+ `
482
+ /**
483
+ * @rule Browse rows never expose raw score values.
484
+ */
485
+ export default definePageRoute({ path: '/browse' });
486
+ `,
487
+ pageFile,
488
+ );
489
+
490
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 1);
491
+ });
492
+
493
+ test('proteum lint resolves anchors against the repo corpus when the app has its own docs directory', () => {
494
+ const { root } = createDocProject();
495
+
496
+ // Mirrors the monorepo layout: apps/<app>/docs/ sits between the source file
497
+ // and the repository-level corpus that the anchor actually points at.
498
+ const appRoot = path.join(root, 'apps', 'website');
499
+ fs.mkdirSync(path.join(appRoot, 'docs', 'fixes'), { recursive: true });
500
+ fs.mkdirSync(path.join(appRoot, 'client', 'pages'), { recursive: true });
501
+
502
+ const messages = lint(
503
+ `
504
+ /**
505
+ * @docs docs/features/search
506
+ */
507
+ export default definePageRoute({ path: '/browse' });
508
+ `,
509
+ path.join(appRoot, 'client', 'pages', 'browse.tsx'),
510
+ );
511
+
512
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 0);
513
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
514
+ });
515
+
516
+ test('proteum lint rejects a doc anchor pointing at a missing document', () => {
517
+ const { pageFile } = createDocProject();
518
+ const messages = lint(
519
+ `
520
+ /**
521
+ * @docs docs/features/deleted-feature
522
+ */
523
+ export default definePageRoute({ path: '/browse' });
524
+ `,
525
+ pageFile,
526
+ );
527
+
528
+ const reported = messagesFor(messages, validDocAnchorRuleId);
529
+ assert.equal(reported.length, 1);
530
+ assert.equal(/docs\/features\/deleted-feature/.test(reported[0].message), true);
531
+ });
532
+
533
+ test('proteum lint resolves fix and decision anchors against the documentation corpus', () => {
534
+ const { pageFile } = createDocProject();
535
+ const messages = lint(
536
+ `
537
+ /**
538
+ * @docs docs/features/search
539
+ * @adr ADR-0004
540
+ * @fix docs/fixes/2026-06-09-keyword-order.md
541
+ * @rule Composite ordering stays alias-aware.
542
+ */
543
+ export default definePageRoute({ path: '/browse' });
544
+ `,
545
+ pageFile,
546
+ );
547
+
548
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 0);
549
+ });
550
+
551
+ test('proteum lint rejects a decision anchor that matches no decision record', () => {
552
+ const { pageFile } = createDocProject();
553
+ const messages = lint(
554
+ `
555
+ /**
556
+ * @docs docs/features/search
557
+ * @adr ADR-9999
558
+ */
559
+ export default definePageRoute({ path: '/browse' });
560
+ `,
561
+ pageFile,
562
+ );
563
+
564
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 1);
565
+ });
566
+
567
+ test('proteum lint rejects a rule anchor that states no invariant', () => {
568
+ const { pageFile } = createDocProject();
569
+ const messages = lint(
570
+ `
571
+ /**
572
+ * @docs docs/features/search
573
+ * @rule todo
574
+ */
575
+ export default definePageRoute({ path: '/browse' });
576
+ `,
577
+ pageFile,
578
+ );
579
+
580
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 1);
581
+ });
582
+
583
+ test('proteum lint validates anchors on files that export no definition', () => {
584
+ const { root } = createDocProject();
585
+ const messages = lint(
586
+ `
587
+ /**
588
+ * @docs docs/features/deleted-feature
589
+ */
590
+ export const helper = () => null;
591
+ `,
592
+ path.join(root, 'server', 'services', 'search.ts'),
593
+ );
594
+
595
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 1);
596
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
597
+ });
598
+
599
+ test('proteum lint escalates and disables doc anchor rules through config options', () => {
600
+ const { pageFile } = createDocProject();
601
+ const source = `
602
+ /**
603
+ * @docs docs/features/deleted-feature
604
+ */
605
+ export default definePageRoute({ path: '/browse' });
606
+ `;
607
+
608
+ const warned = lint(`export default definePageRoute({ path: '/browse' });`, pageFile);
609
+ assert.equal(messagesFor(warned, requireDocAnchorRuleId)[0].severity, 1);
610
+
611
+ const escalated = lint(`export default definePageRoute({ path: '/browse' });`, pageFile, {
612
+ docAnchors: 'error',
613
+ });
614
+ assert.equal(messagesFor(escalated, requireDocAnchorRuleId)[0].severity, 2);
615
+
616
+ const disabled = lint(source, pageFile, { docAnchors: 'off' });
617
+ assert.equal(messagesFor(disabled, requireDocAnchorRuleId).length, 0);
618
+ assert.equal(messagesFor(disabled, validDocAnchorRuleId).length, 0);
619
+ });
@@ -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
+ });