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.
- package/agents/project/CODING_STYLE.md +2 -1
- package/eslint.js +116 -12
- package/package.json +1 -1
- package/tests/eslint-rules.test.cjs +83 -0
|
@@ -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
|
-
|
|
494
|
-
|
|
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
|
|
503
|
-
if (
|
|
504
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|