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.
- package/agents/project/CODING_STYLE.md +2 -0
- package/cli/commands/docs.ts +74 -1
- package/eslint.js +19 -3
- package/package.json +1 -1
- package/tests/docs-check.test.cjs +56 -0
- package/tests/eslint-rules.test.cjs +44 -0
|
@@ -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.
|
package/cli/commands/docs.ts
CHANGED
|
@@ -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 = ({
|
|
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': [
|
|
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.
|
|
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(
|