proteum 2.5.11 → 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.
@@ -83,7 +83,8 @@ Write anchors in a leading block comment:
83
83
  */
84
84
  ```
85
85
 
86
- - `@docs` points at the feature pack that owns the file. Required on every file that default-exports `definePageRoute`, `defineController`, `defineServerRoute`, or `defineServerRoutes`. Error routes are exempt: they render a status message and carry no feature-specific rule, so requiring a pack for one would manufacture documentation. Add an anchor to an error route only when it really does carry a rule.
86
+ - `@docs` points at the feature pack that owns the file. Required on every file that default-exports `definePageRoute`, `defineController`, `defineServerRoute`, or `defineServerRoutes`, and on every exported class extending a service base such as `Service` or `UsersManagementService`. Error routes are exempt: they render a status message and carry no feature-specific rule, so requiring a pack for one would manufacture documentation. Add an anchor to an error route only when it really does carry a rule.
87
+ - Components are not covered automatically. A presentational primitive such as `Icon.tsx` or `Card.tsx` owns no feature, so a blanket rule would manufacture documentation for hundreds of files. Cover the component directories that do own a feature by listing them in `includeDocAnchors` when building the ESLint config, for example `['client/components/paywall/**']`.
87
88
  - `@adr` and `@fix` point at the decision record and fix note that constrain the file. Add them where the decision or the bug actually lives, not on every file in the area.
88
89
  - `@rule` states the invariant inline, in full. It is the one anchor that carries content rather than a pointer, because the rule is what an agent needs at the moment of editing. A `@rule` that only says `todo` or repeats the linked title is a defect.
89
90
  - Anchors are not a substitute for the documents. Narrative, alternatives, benchmarks and acceptance stay under `docs/**`; the anchor carries the pointer and the single-sentence rule.
package/eslint.js CHANGED
@@ -460,6 +460,74 @@ const getDefinitionCalleeName = (node, definitions) => {
460
460
  return name && definitions.includes(name) ? name : null;
461
461
  };
462
462
 
463
+ const getSuperClassName = (node) => {
464
+ const superClass = node?.superClass;
465
+ if (!superClass) return null;
466
+ if (superClass.type === 'Identifier') return superClass.name;
467
+ // `extends Service<Config, Hooks, App, object>` parses the generic call as a
468
+ // member or call expression depending on the parser path.
469
+ if (superClass.type === 'CallExpression') return getCalleePropertyName(superClass.callee);
470
+ if (superClass.type === 'MemberExpression') return getMemberPropertyName(superClass);
471
+
472
+ return null;
473
+ };
474
+
475
+ /**
476
+ * An exported class extending a Proteum service base owns business logic, so it
477
+ * carries the same documentation obligation as a route or controller. Matching
478
+ * on the base-class suffix covers both `extends Service` and app-specific bases
479
+ * such as `extends UsersManagementService`.
480
+ */
481
+ const isServiceClass = (node, pattern) => {
482
+ if (node?.type !== 'ClassDeclaration') return false;
483
+
484
+ const superClassName = getSuperClassName(node);
485
+ return Boolean(superClassName && pattern.test(superClassName));
486
+ };
487
+
488
+ const globToRegExp = (glob) => {
489
+ let pattern = '';
490
+
491
+ for (let index = 0; index < glob.length; index += 1) {
492
+ const character = glob[index];
493
+
494
+ if (character === '*') {
495
+ if (glob[index + 1] === '*') {
496
+ // `**/` spans any number of directories; a trailing `**` spans
497
+ // the rest of the path, separators included.
498
+ if (glob[index + 2] === '/') {
499
+ pattern += '(?:[^/]*\/)*';
500
+ index += 2;
501
+ continue;
502
+ }
503
+
504
+ pattern += '.*';
505
+ index += 1;
506
+ continue;
507
+ }
508
+
509
+ pattern += '[^/]*';
510
+ continue;
511
+ }
512
+
513
+ pattern += '.+^${}()|[]\\?'.includes(character) ? `\\${character}` : character;
514
+ }
515
+
516
+ return new RegExp(`(^|/)${pattern}$`);
517
+ };
518
+
519
+ const includeMatchers = new Map();
520
+
521
+ const matchesIncludeGlob = (filename, includes) => {
522
+ if (!filename || includes.length === 0) return false;
523
+
524
+ const normalized = filename.replace(/\\/g, '/');
525
+ return includes.some((glob) => {
526
+ if (!includeMatchers.has(glob)) includeMatchers.set(glob, globToRegExp(glob));
527
+ return includeMatchers.get(glob).test(normalized);
528
+ });
529
+ };
530
+
463
531
  const createRequireDocAnchorRule = () => ({
464
532
  meta: {
465
533
  type: 'suggestion',
@@ -477,7 +545,9 @@ const createRequireDocAnchorRule = () => ({
477
545
  type: 'object',
478
546
  properties: {
479
547
  definitions: { type: 'array', items: { type: 'string' } },
548
+ include: { type: 'array', items: { type: 'string' } },
480
549
  requiredTags: { type: 'array', items: { enum: docAnchorTags } },
550
+ serviceBasePattern: { type: 'string' },
481
551
  },
482
552
  additionalProperties: false,
483
553
  },
@@ -487,22 +557,52 @@ const createRequireDocAnchorRule = () => ({
487
557
  const options = context.options?.[0] || {};
488
558
  const definitions = options.definitions || defaultDocAnchorDefinitions;
489
559
  const requiredTags = options.requiredTags || ['docs'];
560
+ const includes = options.include || [];
561
+ const serviceBasePattern = new RegExp(options.serviceBasePattern || 'Service$');
562
+ const filename = context.filename || context.getFilename?.() || '';
563
+
564
+ let reported = false;
565
+
566
+ const reportMissing = (node, subject) => {
567
+ // One report per file: a service class and an include glob can both
568
+ // match, and repeating the same instruction adds no information.
569
+ if (reported) return;
570
+
571
+ const anchors = collectFileDocAnchors(context);
572
+ if (anchors.entries.length === 0) {
573
+ reported = true;
574
+ context.report({ node, messageId: 'missingAnchor', data: { definition: subject } });
575
+ return;
576
+ }
577
+
578
+ const missingTag = requiredTags.find((tag) => !hasDocAnchorTag(anchors, tag));
579
+ if (missingTag) {
580
+ reported = true;
581
+ context.report({ node, messageId: 'missingTag', data: { definition: subject, tag: missingTag } });
582
+ }
583
+ };
584
+
585
+ const checkClass = (node) => {
586
+ if (!isServiceClass(node, serviceBasePattern)) return;
587
+ reportMissing(node, getSuperClassName(node));
588
+ };
490
589
 
491
590
  return {
492
591
  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 } });
592
+ if (node.declaration?.type === 'ClassDeclaration') {
593
+ checkClass(node.declaration);
499
594
  return;
500
595
  }
501
596
 
502
- const missingTag = requiredTags.find((tag) => !hasDocAnchorTag(anchors, tag));
503
- if (missingTag) {
504
- context.report({ node, messageId: 'missingTag', data: { definition, tag: missingTag } });
505
- }
597
+ const definition = getDefinitionCalleeName(node.declaration, definitions);
598
+ if (definition) reportMissing(node, definition);
599
+ },
600
+ ExportNamedDeclaration(node) {
601
+ if (node.declaration?.type === 'ClassDeclaration') checkClass(node.declaration);
602
+ },
603
+ 'Program:exit'(node) {
604
+ if (!matchesIncludeGlob(filename, includes)) return;
605
+ reportMissing(node, path.basename(filename));
506
606
  },
507
607
  };
508
608
  },
@@ -561,7 +661,7 @@ const createValidDocAnchorRule = () => ({
561
661
  },
562
662
  });
563
663
 
564
- const createProteumEslintConfig = ({ docAnchors = 'warn', ignores = [] } = {}) => [
664
+ const createProteumEslintConfig = ({ docAnchors = 'warn', includeDocAnchors = [], ignores = [] } = {}) => [
565
665
  {
566
666
  ignores: [...defaultIgnores, ...ignores],
567
667
  },
@@ -602,7 +702,11 @@ const createProteumEslintConfig = ({ docAnchors = 'warn', ignores = [] } = {}) =
602
702
  'proteum/no-swallowed-caught-error': 'error',
603
703
  // Missing anchors warn by default so adopting apps see the backlog
604
704
  // without a failing build; pass `docAnchors: 'error'` once backfilled.
605
- 'proteum/require-doc-anchor': docAnchors,
705
+ // Routes, controllers and service classes are covered automatically.
706
+ // `includeDocAnchors` opts in extra paths, which is how a project
707
+ // covers the feature-owning components without dragging in every
708
+ // presentational primitive.
709
+ 'proteum/require-doc-anchor': [docAnchors, { include: includeDocAnchors }],
606
710
  // A stale anchor is always an error: it only fires on files that
607
711
  // already opted in, and a pointer to a deleted document is worse
608
712
  // than no pointer at all.
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.11",
4
+ "version": "2.5.12",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -458,6 +458,89 @@ test('proteum lint requires a doc anchor on every Proteum definition kind', () =
458
458
  }
459
459
  });
460
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
+
461
544
  test('proteum lint does not require a doc anchor on error routes', () => {
462
545
  const { root } = createDocProject();
463
546
  const messages = lint(