proteum 2.5.12 → 2.5.14

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.
@@ -85,6 +85,8 @@ Write anchors in a leading block comment:
85
85
 
86
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
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/**']`.
88
+ - Infrastructure is exempt the same way error routes are. A transport helper, a metrics router or a rate limiter owns no feature contract, so list those paths in `excludeDocAnchors` rather than pointing them at a pack that does not describe them. An excluded file may still declare anchors, and `valid-doc-anchor` keeps checking any it declares.
89
+ - A feature pack that no code will ever point at, such as a retirement record or a document describing audiences rather than modules, opts out of the orphan report by carrying a `RETIRED` note or a `code-owned: false` line in its README. Without that, the pack is reported as orphaned forever and the backlog stops being actionable.
88
90
  - `@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.
89
91
  - `@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.
90
92
  - 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.
@@ -8,6 +8,7 @@ import { renderStep, renderSuccess, renderTitle, renderWarning } from '../presen
8
8
 
9
9
  const { collectDocAnchors } = require('../../docAnchors.js') as {
10
10
  collectDocAnchors: (sourceText: string) => {
11
+ adr: string[];
11
12
  docs: string[];
12
13
  fix: string[];
13
14
  entries: { line: number; tag: string; value: string }[];
@@ -20,7 +21,7 @@ const { collectDocAnchors } = require('../../docAnchors.js') as {
20
21
 
21
22
  type TDocsCheckFinding = {
22
23
  detail: string;
23
- kind: 'unresolved-anchor' | 'unanchored-fix-note' | 'orphan-feature-pack';
24
+ kind: 'unresolved-anchor' | 'ambiguous-anchor' | 'unanchored-fix-note' | 'orphan-feature-pack';
24
25
  subject: string;
25
26
  };
26
27
 
@@ -96,6 +97,58 @@ const resolveAnchorValue = (value: string, fromDirectory: string, root: string)
96
97
  return roots.some((candidate) => fs.existsSync(path.resolve(candidate, value)));
97
98
  };
98
99
 
100
+ /**
101
+ * Resolve an `@adr` reference to the decision records whose filename starts with
102
+ * it. Returns every match, because two records sharing an identifier makes the
103
+ * reference ambiguous rather than merely valid.
104
+ */
105
+ const resolveAdrReference = (value: string, fromDirectory: string, root: string) => {
106
+ const normalized = value.trim().toLowerCase();
107
+ if (!normalized) return [];
108
+
109
+ const directories: string[] = [];
110
+ let current = fromDirectory;
111
+ while (current) {
112
+ const candidate = path.join(current, 'docs', 'decisions');
113
+ if (fs.existsSync(candidate)) directories.push(candidate);
114
+ const parent = path.dirname(current);
115
+ if (parent === current) break;
116
+ current = parent;
117
+ }
118
+
119
+ const rootCandidate = path.join(root, 'docs', 'decisions');
120
+ if (fs.existsSync(rootCandidate) && !directories.includes(rootCandidate)) directories.push(rootCandidate);
121
+
122
+ // A project with no decisions corpus never fails this check.
123
+ if (directories.length === 0) return null;
124
+
125
+ return directories.flatMap((directory) =>
126
+ fs
127
+ .readdirSync(directory)
128
+ .filter((entry) => entry.toLowerCase().startsWith(normalized))
129
+ .map((entry) => path.relative(root, path.join(directory, entry))),
130
+ );
131
+ };
132
+
133
+ const retiredPackPattern = /^\s*>?\s*RETIRED\b/im;
134
+ const notCodeOwnedPattern = /^\s*>?\s*code-owned:\s*false\s*$/im;
135
+
136
+ /**
137
+ * A pack opts out of the orphan report by saying so in its README, either with
138
+ * a `RETIRED` note or a `code-owned: false` line.
139
+ *
140
+ * Retirement records and intent documents describe decisions and audiences, not
141
+ * modules, so no source file will ever point at them. Reporting those forever
142
+ * trains a reader to ignore the whole list.
143
+ */
144
+ const packOptsOutOfOrphanReport = (featuresDir: string, pack: string) => {
145
+ const readme = path.join(featuresDir, pack, 'README.md');
146
+ if (!fs.existsSync(readme)) return false;
147
+
148
+ const content = fs.readFileSync(readme, 'utf8');
149
+ return retiredPackPattern.test(content) || notCodeOwnedPattern.test(content);
150
+ };
151
+
99
152
  const listFeaturePacks = (root: string) => {
100
153
  const featuresDir = path.join(root, 'docs', 'features');
101
154
  if (!fs.existsSync(featuresDir)) return [];
@@ -103,6 +156,7 @@ const listFeaturePacks = (root: string) => {
103
156
  return fs
104
157
  .readdirSync(featuresDir, { withFileTypes: true })
105
158
  .filter((entry) => entry.isDirectory())
159
+ .filter((entry) => !packOptsOutOfOrphanReport(featuresDir, entry.name))
106
160
  .map((entry) => entry.name);
107
161
  };
108
162
 
@@ -143,6 +197,24 @@ export const buildDocsCheckReport = (root: string): TDocsCheckReport => {
143
197
  findings.push({ detail: `@fix ${value}`, kind: 'unresolved-anchor', subject: relative });
144
198
  }
145
199
  });
200
+
201
+ anchors.adr.forEach((value) => {
202
+ const matches = resolveAdrReference(value, path.dirname(filepath), root);
203
+ if (matches === null) return;
204
+
205
+ if (matches.length === 0) {
206
+ findings.push({ detail: `@adr ${value}`, kind: 'unresolved-anchor', subject: relative });
207
+ return;
208
+ }
209
+
210
+ if (matches.length > 1) {
211
+ findings.push({
212
+ detail: `@adr ${value} matches ${matches.length} records: ${matches.join(', ')}`,
213
+ kind: 'ambiguous-anchor',
214
+ subject: relative,
215
+ });
216
+ }
217
+ });
146
218
  });
147
219
 
148
220
  listFixNotesRequiringAnchor(root).forEach((note) => {
@@ -202,6 +274,7 @@ export const run = async (): Promise<void> => {
202
274
  { label: 'files anchored', value: String(report.anchoredFiles) },
203
275
  ]),
204
276
  renderFindings(report.findings, 'unresolved-anchor', 'Unresolved anchors'),
277
+ renderFindings(report.findings, 'ambiguous-anchor', 'Ambiguous anchors'),
205
278
  renderFindings(report.findings, 'unanchored-fix-note', 'Fix notes with no code anchor'),
206
279
  renderFindings(report.findings, 'orphan-feature-pack', 'Feature packs with no inbound anchor'),
207
280
  ].join('\n\n'),
package/eslint.js CHANGED
@@ -545,6 +545,7 @@ const createRequireDocAnchorRule = () => ({
545
545
  type: 'object',
546
546
  properties: {
547
547
  definitions: { type: 'array', items: { type: 'string' } },
548
+ exclude: { type: 'array', items: { type: 'string' } },
548
549
  include: { type: 'array', items: { type: 'string' } },
549
550
  requiredTags: { type: 'array', items: { enum: docAnchorTags } },
550
551
  serviceBasePattern: { type: 'string' },
@@ -558,15 +559,22 @@ const createRequireDocAnchorRule = () => ({
558
559
  const definitions = options.definitions || defaultDocAnchorDefinitions;
559
560
  const requiredTags = options.requiredTags || ['docs'];
560
561
  const includes = options.include || [];
562
+ const excludes = options.exclude || [];
561
563
  const serviceBasePattern = new RegExp(options.serviceBasePattern || 'Service$');
562
564
  const filename = context.filename || context.getFilename?.() || '';
563
565
 
566
+ // Infrastructure carries no feature contract, so a project lists those
567
+ // paths here rather than anchoring them to a pack that does not exist.
568
+ // An excluded file may still declare anchors, and `valid-doc-anchor`
569
+ // keeps checking them.
570
+ const excluded = matchesIncludeGlob(filename, excludes);
571
+
564
572
  let reported = false;
565
573
 
566
574
  const reportMissing = (node, subject) => {
567
575
  // One report per file: a service class and an include glob can both
568
576
  // match, and repeating the same instruction adds no information.
569
- if (reported) return;
577
+ if (reported || excluded) return;
570
578
 
571
579
  const anchors = collectFileDocAnchors(context);
572
580
  if (anchors.entries.length === 0) {
@@ -661,7 +669,12 @@ const createValidDocAnchorRule = () => ({
661
669
  },
662
670
  });
663
671
 
664
- const createProteumEslintConfig = ({ docAnchors = 'warn', includeDocAnchors = [], ignores = [] } = {}) => [
672
+ const createProteumEslintConfig = ({
673
+ docAnchors = 'warn',
674
+ excludeDocAnchors = [],
675
+ includeDocAnchors = [],
676
+ ignores = [],
677
+ } = {}) => [
665
678
  {
666
679
  ignores: [...defaultIgnores, ...ignores],
667
680
  },
@@ -706,7 +719,10 @@ const createProteumEslintConfig = ({ docAnchors = 'warn', includeDocAnchors = []
706
719
  // `includeDocAnchors` opts in extra paths, which is how a project
707
720
  // covers the feature-owning components without dragging in every
708
721
  // presentational primitive.
709
- 'proteum/require-doc-anchor': [docAnchors, { include: includeDocAnchors }],
722
+ 'proteum/require-doc-anchor': [
723
+ docAnchors,
724
+ { exclude: excludeDocAnchors, include: includeDocAnchors },
725
+ ],
710
726
  // A stale anchor is always an error: it only fires on files that
711
727
  // already opted in, and a pointer to a deleted document is worse
712
728
  // 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.12",
4
+ "version": "2.5.14",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -83,6 +83,45 @@ test('docs check resolves anchors aimed at the repo corpus when run from an app
83
83
  assert.equal(report.anchoredFiles, 1);
84
84
  });
85
85
 
86
+ test('docs check resolves an adr anchor against the decisions corpus', () => {
87
+ const root = createRoot();
88
+ writeFile(root, 'docs/decisions/ADR-0004-page-query-contracts.md', '# ADR-0004\n');
89
+ writeFile(root, 'apps/product/server/controllers/search.ts', '/**\n * @adr ADR-0004\n */\nexport default {};\n');
90
+
91
+ assert.equal(kinds(buildDocsCheckReport(root), 'unresolved-anchor').length, 0);
92
+ });
93
+
94
+ test('docs check reports an adr anchor matching no decision record', () => {
95
+ const root = createRoot();
96
+ writeFile(root, 'docs/decisions/ADR-0004-page-query-contracts.md', '# ADR-0004\n');
97
+ writeFile(root, 'apps/product/server/controllers/search.ts', '/**\n * @adr ADR-9999\n */\nexport default {};\n');
98
+
99
+ const unresolved = kinds(buildDocsCheckReport(root), 'unresolved-anchor');
100
+
101
+ assert.equal(unresolved.length, 1);
102
+ assert.equal(unresolved[0].detail, '@adr ADR-9999');
103
+ });
104
+
105
+ test('docs check reports an adr identifier shared by two decision records', () => {
106
+ const root = createRoot();
107
+ writeFile(root, 'docs/decisions/ADR-0010-catalog-provenance.md', '# ADR-0010\n');
108
+ writeFile(root, 'docs/decisions/ADR-0010-visual-system.md', '# ADR-0010\n');
109
+ writeFile(root, 'apps/product/server/controllers/search.ts', '/**\n * @adr ADR-0010\n */\nexport default {};\n');
110
+
111
+ const ambiguous = kinds(buildDocsCheckReport(root), 'ambiguous-anchor');
112
+
113
+ assert.equal(ambiguous.length, 1);
114
+ assert.equal(/matches 2 records/.test(ambiguous[0].detail), true);
115
+ });
116
+
117
+ test('docs check stays silent about adr anchors when a project has no decisions corpus', () => {
118
+ const root = createRoot();
119
+ writeFile(root, 'docs/features/search/README.md', '# Search\n');
120
+ writeFile(root, 'apps/product/server/controllers/search.ts', '/**\n * @adr ADR-0004\n */\nexport default {};\n');
121
+
122
+ assert.equal(kinds(buildDocsCheckReport(root), 'unresolved-anchor').length, 0);
123
+ });
124
+
86
125
  test('docs check reports a fix note whose invariant no code anchors', () => {
87
126
  const root = createRoot();
88
127
  writeFile(root, 'docs/fixes/2026-06-14-watchdog.md', '# Fix\n\n## Agent warning\n\nDo not remove the guards.\n');
@@ -121,6 +160,23 @@ test('docs check reports a feature pack that no code points at', () => {
121
160
  assert.deepEqual(orphans.map((finding) => finding.subject), ['docs/features/orphan']);
122
161
  });
123
162
 
163
+ test('docs check lets a retired or non-code pack opt out of the orphan report', () => {
164
+ const root = createRoot();
165
+ writeFile(root, 'docs/features/search/README.md', '# Search\n');
166
+ writeFile(root, 'docs/features/pro-preview/README.md', '# Pro Preview\n\n> RETIRED 2026-07-07. Superseded.\n');
167
+ writeFile(root, 'docs/features/persona-journeys/README.md', '# Personas\n\ncode-owned: false\n');
168
+ writeFile(root, 'docs/features/still-orphaned/README.md', '# Orphan\n');
169
+ writeFile(
170
+ root,
171
+ 'apps/product/server/controllers/search.ts',
172
+ '/**\n * @docs docs/features/search\n */\nexport default {};\n',
173
+ );
174
+
175
+ const orphans = kinds(buildDocsCheckReport(root), 'orphan-feature-pack');
176
+
177
+ assert.deepEqual(orphans.map((finding) => finding.subject), ['docs/features/still-orphaned']);
178
+ });
179
+
124
180
  test('docs check ignores generated and vendored directories', () => {
125
181
  const root = createRoot();
126
182
  writeFile(root, 'docs/features/search/README.md', '# Search\n');
@@ -541,6 +541,50 @@ test('proteum lint accepts an included file once it carries an anchor', () => {
541
541
  assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
542
542
  });
543
543
 
544
+ test('proteum lint exempts infrastructure paths listed in excludeDocAnchors', () => {
545
+ const { root } = createDocProject();
546
+ const options = { docAnchors: 'warn', excludeDocAnchors: ['server/services/Utils/**', 'server/routes/debug.ts'] };
547
+
548
+ const utilService = lint(
549
+ `export default class FetchService extends Service {}`,
550
+ path.join(root, 'server', 'services', 'Utils', 'Fetch', 'index.ts'),
551
+ options,
552
+ );
553
+ assert.equal(messagesFor(utilService, requireDocAnchorRuleId).length, 0);
554
+
555
+ const debugRoute = lint(
556
+ `export default defineServerRoutes(() => null);`,
557
+ path.join(root, 'server', 'routes', 'debug.ts'),
558
+ options,
559
+ );
560
+ assert.equal(messagesFor(debugRoute, requireDocAnchorRuleId).length, 0);
561
+
562
+ // A service outside the excluded paths is still required to anchor.
563
+ const covered = lint(
564
+ `export default class SearchService extends Service {}`,
565
+ path.join(root, 'server', 'services', 'Domains', 'search', 'index.ts'),
566
+ options,
567
+ );
568
+ assert.equal(messagesFor(covered, requireDocAnchorRuleId).length, 1);
569
+ });
570
+
571
+ test('proteum lint still validates anchors declared on an excluded file', () => {
572
+ const { root } = createDocProject();
573
+ const messages = lint(
574
+ `
575
+ /**
576
+ * @docs docs/features/deleted-feature
577
+ */
578
+ export default class FetchService extends Service {}
579
+ `,
580
+ path.join(root, 'server', 'services', 'Utils', 'Fetch', 'index.ts'),
581
+ { docAnchors: 'warn', excludeDocAnchors: ['server/services/Utils/**'] },
582
+ );
583
+
584
+ assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
585
+ assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 1);
586
+ });
587
+
544
588
  test('proteum lint does not require a doc anchor on error routes', () => {
545
589
  const { root } = createDocProject();
546
590
  const messages = lint(