proteum 2.5.9 → 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.
- package/AGENTS.md +4 -4
- package/agents/project/AGENTS.md +131 -91
- package/agents/project/CODING_STYLE.md +69 -40
- package/agents/project/DOCUMENTATION.md +19 -2
- package/agents/project/client/AGENTS.md +0 -1
- package/agents/project/diagnostics.md +6 -5
- package/agents/project/optimizations.md +1 -7
- package/agents/project/server/services/AGENTS.md +1 -3
- package/agents/project/tests/AGENTS.md +3 -3
- package/cli/commands/docs.ts +223 -0
- package/cli/commands/session.ts +36 -5
- package/cli/commands/verify.ts +6 -1
- package/cli/compiler/client/index.ts +11 -4
- package/cli/compiler/common/uiSingletons.ts +76 -0
- package/cli/compiler/server/index.ts +34 -12
- package/cli/presentation/commands.ts +20 -1
- package/cli/runtime/commands.ts +20 -0
- package/cli/scaffold/index.ts +3 -0
- package/cli/scaffold/templates.ts +62 -6
- package/cli/utils/agents.ts +2 -2
- package/cli/verification/changed.ts +21 -0
- package/client/dev/profiler/index.tsx +761 -455
- package/common/dev/mcpPayloads.ts +86 -8
- package/common/dev/session.ts +32 -0
- package/common/errors/index.tsx +0 -1
- package/docAnchors.js +135 -0
- package/docs/agent-routing.md +2 -2
- package/eslint.js +264 -1
- package/package.json +1 -1
- package/server/app/container/console/index.ts +0 -17
- package/server/services/router/http/index.ts +130 -33
- package/tests/agents-utils.test.cjs +0 -4
- package/tests/dev-session-login-url.test.cjs +33 -0
- package/tests/doc-anchors.test.cjs +115 -0
- package/tests/docs-check.test.cjs +138 -0
- package/tests/eslint-rules.test.cjs +235 -2
- package/tests/mcp.test.cjs +109 -0
- package/tests/ui-singletons.test.cjs +104 -0
- package/tests/verify-changed.test.cjs +51 -3
- package/agents/project/app-root/AGENTS.md +0 -14
- package/agents/project/root/AGENTS.md +0 -399
|
@@ -45,8 +45,34 @@ type TNodePath = {
|
|
|
45
45
|
resolve: (...segments: string[]) => string;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
type TDocAnchorEntry = {
|
|
49
|
+
line: number;
|
|
50
|
+
tag: string;
|
|
51
|
+
value: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type TDocAnchorGroups = {
|
|
55
|
+
adr: string[];
|
|
56
|
+
docs: string[];
|
|
57
|
+
entries: TDocAnchorEntry[];
|
|
58
|
+
fix: string[];
|
|
59
|
+
rules: string[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type TDocAnchorsModule = {
|
|
63
|
+
collectDocAnchors: (sourceText: string, options?: { maxLength?: number }) => TDocAnchorGroups;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type TOwnerDocAnchors = {
|
|
67
|
+
adr?: string[];
|
|
68
|
+
docs?: string[];
|
|
69
|
+
fix?: string[];
|
|
70
|
+
rules?: string[];
|
|
71
|
+
};
|
|
72
|
+
|
|
48
73
|
const maxInstructionPreviewLength = 360;
|
|
49
74
|
const maxTextLength = 220;
|
|
75
|
+
const maxOwnerDocAnchorRules = 3;
|
|
50
76
|
const nodeRequire = (() => {
|
|
51
77
|
try {
|
|
52
78
|
return eval('require') as NodeRequire;
|
|
@@ -56,6 +82,23 @@ const nodeRequire = (() => {
|
|
|
56
82
|
})();
|
|
57
83
|
const fs = nodeRequire ? (nodeRequire('fs') as TNodeFs) : undefined;
|
|
58
84
|
const path = nodeRequire ? (nodeRequire('path') as TNodePath) : undefined;
|
|
85
|
+
const docAnchors = (() => {
|
|
86
|
+
if (!nodeRequire) return undefined;
|
|
87
|
+
|
|
88
|
+
// Relative resolution covers the linked framework checkout; the package
|
|
89
|
+
// specifier covers an installed copy whose transpiled layout may differ.
|
|
90
|
+
// Owner payloads stay valid without anchors, so a resolution failure
|
|
91
|
+
// degrades to the previous behaviour instead of breaking the tool.
|
|
92
|
+
for (const specifier of ['../../docAnchors.js', 'proteum/docAnchors.js']) {
|
|
93
|
+
try {
|
|
94
|
+
return nodeRequire(specifier) as TDocAnchorsModule;
|
|
95
|
+
} catch (_error) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return undefined;
|
|
101
|
+
})();
|
|
59
102
|
|
|
60
103
|
const hasNodeFs = () => fs !== undefined;
|
|
61
104
|
const hasNodePath = () => path !== undefined;
|
|
@@ -217,14 +260,49 @@ export const summarizeManifest = (manifest: TProteumManifest | undefined) => {
|
|
|
217
260
|
};
|
|
218
261
|
};
|
|
219
262
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Resolve the doc anchors declared in an owner's source file.
|
|
265
|
+
*
|
|
266
|
+
* This is what makes the documentation corpus discoverable at the point an
|
|
267
|
+
* agent asks who owns a route: the governing feature pack, decision record and
|
|
268
|
+
* fix note arrive with the owner instead of costing a separate search.
|
|
269
|
+
*/
|
|
270
|
+
export const readOwnerDocAnchors = (filepath?: string): TOwnerDocAnchors | undefined => {
|
|
271
|
+
if (!filepath || !docAnchors || fs === undefined || !fileExists(filepath)) return undefined;
|
|
272
|
+
|
|
273
|
+
let groups: TDocAnchorGroups;
|
|
274
|
+
try {
|
|
275
|
+
groups = docAnchors.collectDocAnchors(fs.readFileSync(filepath, 'utf8'));
|
|
276
|
+
} catch (_error) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (groups.entries.length === 0) return undefined;
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
docs: groups.docs.length > 0 ? groups.docs : undefined,
|
|
284
|
+
adr: groups.adr.length > 0 ? groups.adr : undefined,
|
|
285
|
+
fix: groups.fix.length > 0 ? groups.fix : undefined,
|
|
286
|
+
rules:
|
|
287
|
+
groups.rules.length > 0
|
|
288
|
+
? compactList(groups.rules, maxOwnerDocAnchorRules).map((rule) => truncateForMcp(rule))
|
|
289
|
+
: undefined,
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const compactOwnerMatch = (match: TExplainOwnerResponse['matches'][number]) => {
|
|
294
|
+
const docs = readOwnerDocAnchors(match.source.filepath);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
kind: match.kind,
|
|
298
|
+
label: match.label,
|
|
299
|
+
score: match.score,
|
|
300
|
+
scope: match.scopeLabel,
|
|
301
|
+
origin: match.originHint,
|
|
302
|
+
source: match.source,
|
|
303
|
+
...(docs ? { docs } : {}),
|
|
304
|
+
};
|
|
305
|
+
};
|
|
228
306
|
|
|
229
307
|
const compactDiagnostic = (diagnostic: TDoctorResponse['diagnostics'][number]) => ({
|
|
230
308
|
level: diagnostic.level,
|
package/common/dev/session.ts
CHANGED
|
@@ -22,3 +22,35 @@ export type TDevSessionStartResponse = {
|
|
|
22
22
|
export type TDevSessionErrorResponse = {
|
|
23
23
|
error: string;
|
|
24
24
|
};
|
|
25
|
+
|
|
26
|
+
export const devSessionLoginPath = '/__proteum/session/login';
|
|
27
|
+
export const devSessionStartPath = '/__proteum/session/start';
|
|
28
|
+
|
|
29
|
+
export const normalizeDevSessionRedirectPath = (value: string): string => {
|
|
30
|
+
const redirect = value.trim() || '/';
|
|
31
|
+
if (!redirect.startsWith('/') || redirect.startsWith('//') || redirect.startsWith('/\\') || /[\r\n]/.test(redirect)) {
|
|
32
|
+
throw new Error('Redirect must be a local absolute path such as /dashboard.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return redirect;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const buildDevSessionLoginUrl = ({
|
|
39
|
+
baseUrl,
|
|
40
|
+
email,
|
|
41
|
+
redirect,
|
|
42
|
+
role,
|
|
43
|
+
}: {
|
|
44
|
+
baseUrl: string;
|
|
45
|
+
email: string;
|
|
46
|
+
redirect: string;
|
|
47
|
+
role?: string;
|
|
48
|
+
}): string => {
|
|
49
|
+
const params = new URLSearchParams({
|
|
50
|
+
email,
|
|
51
|
+
redirect: normalizeDevSessionRedirectPath(redirect),
|
|
52
|
+
});
|
|
53
|
+
if (role?.trim()) params.set('role', role.trim());
|
|
54
|
+
|
|
55
|
+
return `${baseUrl.replace(/\/+$/, '')}${devSessionLoginPath}?${params.toString()}`;
|
|
56
|
+
};
|
package/common/errors/index.tsx
CHANGED
package/docAnchors.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared doc-anchor contract.
|
|
3
|
+
*
|
|
4
|
+
* A doc anchor ties a source file to the durable documentation that governs it,
|
|
5
|
+
* so an agent editing the file sees the governing rule without first reading the
|
|
6
|
+
* whole documentation corpus.
|
|
7
|
+
*
|
|
8
|
+
* Supported tags, written in any leading block comment:
|
|
9
|
+
*
|
|
10
|
+
* @docs docs/features/search
|
|
11
|
+
* @adr ADR-0004
|
|
12
|
+
* @fix docs/fixes/2026-06-09-keyword-search-semantic-order.md
|
|
13
|
+
* @rule Composite ordering stays alias-aware. Never rewrite ORDER BY with regex.
|
|
14
|
+
*
|
|
15
|
+
* `@docs`, `@adr` and `@fix` are pointers resolved by the MCP owner payloads.
|
|
16
|
+
* `@rule` carries the one-line invariant inline, where an agent cannot miss it.
|
|
17
|
+
*
|
|
18
|
+
* This module is plain CommonJS with no dependencies because both the ESLint
|
|
19
|
+
* rules and the TypeScript MCP payload builder load it. Keep it that way so the
|
|
20
|
+
* lint surface and the agent-facing surface can never disagree on the format.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const docAnchorTags = ['docs', 'adr', 'fix', 'rule'];
|
|
24
|
+
const pathDocAnchorTags = ['docs', 'fix'];
|
|
25
|
+
const maxDocAnchorScanLength = 64 * 1024;
|
|
26
|
+
const minDocAnchorRuleLength = 8;
|
|
27
|
+
|
|
28
|
+
const blockCommentPattern = /\/\*[\s\S]*?\*\//g;
|
|
29
|
+
const commentGutterPattern = /^\s*\*+[ \t]?/;
|
|
30
|
+
const tagPattern = /^@([a-zA-Z][\w-]*)[ \t]*(.*)$/;
|
|
31
|
+
|
|
32
|
+
const countLinesBefore = (text, index) => {
|
|
33
|
+
let line = 1;
|
|
34
|
+
for (let cursor = 0; cursor < index; cursor += 1) {
|
|
35
|
+
if (text[cursor] === '\n') line += 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return line;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const stripCommentGutter = (line) => line.replace(commentGutterPattern, '').trim();
|
|
42
|
+
|
|
43
|
+
const isDocAnchorTag = (tag) => docAnchorTags.includes(tag);
|
|
44
|
+
const isPathDocAnchorTag = (tag) => pathDocAnchorTags.includes(tag);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse the inner text of a single block comment.
|
|
48
|
+
*
|
|
49
|
+
* `startLine` is the 1-based line the comment opens on, so reported entries
|
|
50
|
+
* point at the exact anchor line rather than the top of the file.
|
|
51
|
+
*/
|
|
52
|
+
const parseDocAnchorComment = (commentValue, startLine = 1) => {
|
|
53
|
+
const entries = [];
|
|
54
|
+
const lines = String(commentValue === undefined || commentValue === null ? '' : commentValue).split('\n');
|
|
55
|
+
let current;
|
|
56
|
+
|
|
57
|
+
lines.forEach((rawLine, index) => {
|
|
58
|
+
const line = stripCommentGutter(rawLine);
|
|
59
|
+
if (line === '') {
|
|
60
|
+
current = undefined;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tagMatch = line.match(tagPattern);
|
|
65
|
+
if (tagMatch) {
|
|
66
|
+
const tag = tagMatch[1].toLowerCase();
|
|
67
|
+
if (!isDocAnchorTag(tag)) {
|
|
68
|
+
current = undefined;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
current = { tag, value: tagMatch[2].trim(), line: startLine + index };
|
|
73
|
+
entries.push(current);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A non-empty line that opens no new tag continues the previous value,
|
|
78
|
+
// which keeps multi-line `@rule` invariants readable in source.
|
|
79
|
+
if (current) current.value = `${current.value} ${line}`.trim();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return entries.filter((entry) => entry.value !== '');
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const uniqueValues = (entries, tag) => {
|
|
86
|
+
const values = [];
|
|
87
|
+
entries.forEach((entry) => {
|
|
88
|
+
if (entry.tag !== tag || values.includes(entry.value)) return;
|
|
89
|
+
values.push(entry.value);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return values;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const buildDocAnchorGroups = (entries) => ({
|
|
96
|
+
entries,
|
|
97
|
+
docs: uniqueValues(entries, 'docs'),
|
|
98
|
+
adr: uniqueValues(entries, 'adr'),
|
|
99
|
+
fix: uniqueValues(entries, 'fix'),
|
|
100
|
+
rules: uniqueValues(entries, 'rule'),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const hasDocAnchorTag = (groups, tag) => groups.entries.some((entry) => entry.tag === tag);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Collect every doc anchor in raw source text.
|
|
107
|
+
*
|
|
108
|
+
* Used by the MCP owner payloads, which read a file from disk and have no AST.
|
|
109
|
+
* The scan is capped so enriching an owner match stays cheap on large files.
|
|
110
|
+
*/
|
|
111
|
+
const collectDocAnchors = (sourceText, options) => {
|
|
112
|
+
const maxLength = options && options.maxLength ? options.maxLength : maxDocAnchorScanLength;
|
|
113
|
+
const text = String(sourceText === undefined || sourceText === null ? '' : sourceText).slice(0, maxLength);
|
|
114
|
+
const entries = [];
|
|
115
|
+
|
|
116
|
+
blockCommentPattern.lastIndex = 0;
|
|
117
|
+
let match = blockCommentPattern.exec(text);
|
|
118
|
+
while (match !== null) {
|
|
119
|
+
const body = match[0].slice(2, -2);
|
|
120
|
+
entries.push(...parseDocAnchorComment(body, countLinesBefore(text, match.index)));
|
|
121
|
+
match = blockCommentPattern.exec(text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return buildDocAnchorGroups(entries);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
buildDocAnchorGroups,
|
|
129
|
+
collectDocAnchors,
|
|
130
|
+
docAnchorTags,
|
|
131
|
+
hasDocAnchorTag,
|
|
132
|
+
isPathDocAnchorTag,
|
|
133
|
+
minDocAnchorRuleLength,
|
|
134
|
+
parseDocAnchorComment,
|
|
135
|
+
};
|
package/docs/agent-routing.md
CHANGED
|
@@ -96,11 +96,11 @@ Standard triggered reads:
|
|
|
96
96
|
- Worktree Preflight (`cwd` inside `/.codex/worktrees/`, newly created Proteum worktree, or before editing in a Codex worktree): root contract fallback, then `npx proteum worktree init --source <source-app-root>` or the returned `--refresh` command, `npx proteum runtime status`, and tracked `npx proteum dev` for runtime-visible work.
|
|
97
97
|
- Git lifecycle (`commit`, `and commit`, `stage`, `push`, `PR`, pull request): root contract fallback.
|
|
98
98
|
- Before git writes after a bug fix, behavior change, decision change, or docs-relevant production change: `DOCUMENTATION.md`.
|
|
99
|
-
- Before finishing production code changes: root contract fallback, `DOCUMENTATION.md`, `CODING_STYLE.md`, and touched area `AGENTS.md
|
|
99
|
+
- Before finishing production code changes: root contract fallback, `DOCUMENTATION.md`, `CODING_STYLE.md`, and touched area `AGENTS.md`, then the `CODING_STYLE.md` self-check on the final diff; a non-obvious decision without a why-comment is a defect.
|
|
100
100
|
- Runtime-visible, request-time, router, SSR, browser, or controller behavior: root contract fallback plus `diagnostics.md`.
|
|
101
101
|
- Bug fixes, regressions, incidents, broken public routes, auth/OAuth failures, integration failures, or production behavior fixes: `DOCUMENTATION.md`.
|
|
102
102
|
- Non-trivial feature, product, business-rule, UX, copy, or docs changes: `DOCUMENTATION.md`.
|
|
103
|
-
- Implementation edits: `CODING_STYLE.md` plus the matching area file from the routing table.
|
|
103
|
+
- Implementation edits: `CODING_STYLE.md` plus the matching area file from the routing table; record non-obvious decisions, workarounds, and constraints as why-comments while editing.
|
|
104
104
|
|
|
105
105
|
`workflow_start`, `orient`, `route_candidates`, and MCP `instructions_resolve` should promote obvious triggered files into selected instruction previews; ambiguous conditional reads can remain in `readWhen`.
|
|
106
106
|
|
package/eslint.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
1
3
|
const tseslint = require('typescript-eslint');
|
|
2
4
|
const reactPlugin = require('eslint-plugin-react');
|
|
3
5
|
const reactHooksPlugin = require('eslint-plugin-react-hooks');
|
|
4
6
|
const jsxA11yPlugin = require('eslint-plugin-jsx-a11y');
|
|
5
7
|
|
|
8
|
+
const {
|
|
9
|
+
buildDocAnchorGroups,
|
|
10
|
+
docAnchorTags,
|
|
11
|
+
hasDocAnchorTag,
|
|
12
|
+
isPathDocAnchorTag,
|
|
13
|
+
minDocAnchorRuleLength,
|
|
14
|
+
parseDocAnchorComment,
|
|
15
|
+
} = require('./docAnchors.js');
|
|
16
|
+
|
|
6
17
|
const defaultIgnores = [
|
|
7
18
|
'**/node_modules/**',
|
|
8
19
|
'**/bin/**',
|
|
@@ -307,7 +318,250 @@ const createNoAppImportRule = () => ({
|
|
|
307
318
|
},
|
|
308
319
|
});
|
|
309
320
|
|
|
310
|
-
|
|
321
|
+
// Error routes are deliberately absent: a `_messages/404` page renders a status
|
|
322
|
+
// message and carries no feature-specific rule, so requiring a feature pack for
|
|
323
|
+
// one would manufacture documentation to satisfy the linter. An error page that
|
|
324
|
+
// does carry a real rule can still add an anchor, and `valid-doc-anchor` keeps
|
|
325
|
+
// checking it.
|
|
326
|
+
const defaultDocAnchorDefinitions = [
|
|
327
|
+
'defineController',
|
|
328
|
+
'definePageRoute',
|
|
329
|
+
'defineServerRoute',
|
|
330
|
+
'defineServerRoutes',
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
const docsRootCache = new Map();
|
|
334
|
+
const directoryEntriesCache = new Map();
|
|
335
|
+
|
|
336
|
+
const getSourceCode = (context) => context.sourceCode || context.getSourceCode?.();
|
|
337
|
+
|
|
338
|
+
const getContextCwd = (context) => context.cwd || context.getCwd?.() || process.cwd();
|
|
339
|
+
|
|
340
|
+
const pathExists = (candidate) => {
|
|
341
|
+
try {
|
|
342
|
+
return fs.existsSync(candidate);
|
|
343
|
+
} catch (_error) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const readDirectoryEntries = (directory) => {
|
|
349
|
+
if (directoryEntriesCache.has(directory)) return directoryEntriesCache.get(directory);
|
|
350
|
+
|
|
351
|
+
let entries = [];
|
|
352
|
+
try {
|
|
353
|
+
entries = fs.readdirSync(directory);
|
|
354
|
+
} catch (_error) {
|
|
355
|
+
entries = [];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
directoryEntriesCache.set(directory, entries);
|
|
359
|
+
return entries;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Collect every ancestor of a linted file that holds a `docs/` directory,
|
|
364
|
+
* nearest first.
|
|
365
|
+
*
|
|
366
|
+
* All of them are candidates, not just the nearest: a monorepo app commonly has
|
|
367
|
+
* its own `apps/<app>/docs/` alongside the repository-level corpus, and stopping
|
|
368
|
+
* at the first match would make every anchor aimed at the shared corpus fail to
|
|
369
|
+
* resolve.
|
|
370
|
+
*/
|
|
371
|
+
const findDocsRoots = (startDirectory) => {
|
|
372
|
+
if (docsRootCache.has(startDirectory)) return docsRootCache.get(startDirectory);
|
|
373
|
+
|
|
374
|
+
const resolved = [];
|
|
375
|
+
let current = startDirectory;
|
|
376
|
+
while (current) {
|
|
377
|
+
if (pathExists(path.join(current, 'docs'))) resolved.push(current);
|
|
378
|
+
|
|
379
|
+
const parent = path.dirname(current);
|
|
380
|
+
if (parent === current) break;
|
|
381
|
+
current = parent;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
docsRootCache.set(startDirectory, resolved);
|
|
385
|
+
return resolved;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const resolveAnchorRoots = (context) => {
|
|
389
|
+
const filename = context.filename || context.getFilename?.() || '';
|
|
390
|
+
const fileDirectory = filename ? path.dirname(filename) : undefined;
|
|
391
|
+
const cwd = getContextCwd(context);
|
|
392
|
+
const roots = [];
|
|
393
|
+
|
|
394
|
+
const addRoot = (root) => {
|
|
395
|
+
if (root && !roots.includes(root)) roots.push(root);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
if (fileDirectory) findDocsRoots(fileDirectory).forEach(addRoot);
|
|
399
|
+
addRoot(cwd);
|
|
400
|
+
addRoot(fileDirectory);
|
|
401
|
+
|
|
402
|
+
return roots;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const resolvePathAnchor = (value, roots) => {
|
|
406
|
+
if (path.isAbsolute(value)) return pathExists(value);
|
|
407
|
+
|
|
408
|
+
return roots.some((root) => pathExists(path.resolve(root, value)));
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const resolveAdrAnchor = (value, roots) => {
|
|
412
|
+
const normalized = value.trim().toLowerCase();
|
|
413
|
+
if (!normalized) return false;
|
|
414
|
+
|
|
415
|
+
const decisionDirectories = roots
|
|
416
|
+
.map((root) => path.join(root, 'docs', 'decisions'))
|
|
417
|
+
.filter((directory) => pathExists(directory));
|
|
418
|
+
|
|
419
|
+
// Projects without a decisions corpus never fail this check, so the rule
|
|
420
|
+
// stays silent instead of inventing a convention the app has not adopted.
|
|
421
|
+
if (decisionDirectories.length === 0) return true;
|
|
422
|
+
|
|
423
|
+
return decisionDirectories.some((directory) =>
|
|
424
|
+
readDirectoryEntries(directory).some((entry) => entry.toLowerCase().startsWith(normalized)),
|
|
425
|
+
);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const collectFileDocAnchors = (context) => {
|
|
429
|
+
const sourceCode = getSourceCode(context);
|
|
430
|
+
if (!sourceCode) return buildDocAnchorGroups([]);
|
|
431
|
+
|
|
432
|
+
const entries = [];
|
|
433
|
+
sourceCode.getAllComments().forEach((comment) => {
|
|
434
|
+
if (comment.type !== 'Block') return;
|
|
435
|
+
entries.push(...parseDocAnchorComment(comment.value, comment.loc?.start?.line || 1));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
return buildDocAnchorGroups(entries);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const unwrapExpression = (node) => {
|
|
442
|
+
let current = node;
|
|
443
|
+
while (
|
|
444
|
+
current &&
|
|
445
|
+
(current.type === 'TSAsExpression' ||
|
|
446
|
+
current.type === 'TSSatisfiesExpression' ||
|
|
447
|
+
current.type === 'TSNonNullExpression')
|
|
448
|
+
) {
|
|
449
|
+
current = current.expression;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return current;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const getDefinitionCalleeName = (node, definitions) => {
|
|
456
|
+
const expression = unwrapExpression(node);
|
|
457
|
+
if (expression?.type !== 'CallExpression') return null;
|
|
458
|
+
|
|
459
|
+
const name = getCalleePropertyName(expression.callee);
|
|
460
|
+
return name && definitions.includes(name) ? name : null;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const createRequireDocAnchorRule = () => ({
|
|
464
|
+
meta: {
|
|
465
|
+
type: 'suggestion',
|
|
466
|
+
docs: {
|
|
467
|
+
description: 'Require Proteum definition files to anchor the documentation that governs them.',
|
|
468
|
+
},
|
|
469
|
+
messages: {
|
|
470
|
+
missingAnchor:
|
|
471
|
+
'`{{definition}}` files must carry a doc anchor. Add a leading block comment with `@docs <path to the feature pack>`, plus `@rule <one-line invariant>` when a fix or decision constrains this file.',
|
|
472
|
+
missingTag:
|
|
473
|
+
'`{{definition}}` files must carry a `@{{tag}}` doc anchor in a leading block comment.',
|
|
474
|
+
},
|
|
475
|
+
schema: [
|
|
476
|
+
{
|
|
477
|
+
type: 'object',
|
|
478
|
+
properties: {
|
|
479
|
+
definitions: { type: 'array', items: { type: 'string' } },
|
|
480
|
+
requiredTags: { type: 'array', items: { enum: docAnchorTags } },
|
|
481
|
+
},
|
|
482
|
+
additionalProperties: false,
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
},
|
|
486
|
+
create(context) {
|
|
487
|
+
const options = context.options?.[0] || {};
|
|
488
|
+
const definitions = options.definitions || defaultDocAnchorDefinitions;
|
|
489
|
+
const requiredTags = options.requiredTags || ['docs'];
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
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 } });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const missingTag = requiredTags.find((tag) => !hasDocAnchorTag(anchors, tag));
|
|
503
|
+
if (missingTag) {
|
|
504
|
+
context.report({ node, messageId: 'missingTag', data: { definition, tag: missingTag } });
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const createValidDocAnchorRule = () => ({
|
|
512
|
+
meta: {
|
|
513
|
+
type: 'problem',
|
|
514
|
+
docs: {
|
|
515
|
+
description: 'Require doc anchors to point at documentation that still exists.',
|
|
516
|
+
},
|
|
517
|
+
messages: {
|
|
518
|
+
unresolvedPath:
|
|
519
|
+
'Doc anchor `@{{tag}} {{value}}` does not resolve to a file or directory. Update the anchor to the current documentation path, or remove it.',
|
|
520
|
+
unresolvedAdr:
|
|
521
|
+
'Doc anchor `@adr {{value}}` matches no decision record under `docs/decisions`. Use the current ADR identifier.',
|
|
522
|
+
emptyRule:
|
|
523
|
+
'Doc anchor `@rule` must state the invariant in full so an agent editing this file can apply it without opening the linked document.',
|
|
524
|
+
},
|
|
525
|
+
schema: [],
|
|
526
|
+
},
|
|
527
|
+
create(context) {
|
|
528
|
+
return {
|
|
529
|
+
'Program:exit'() {
|
|
530
|
+
const anchors = collectFileDocAnchors(context);
|
|
531
|
+
if (anchors.entries.length === 0) return;
|
|
532
|
+
|
|
533
|
+
const roots = resolveAnchorRoots(context);
|
|
534
|
+
anchors.entries.forEach((entry) => {
|
|
535
|
+
const loc = { line: entry.line, column: 0 };
|
|
536
|
+
|
|
537
|
+
if (entry.tag === 'rule') {
|
|
538
|
+
if (entry.value.length < minDocAnchorRuleLength) {
|
|
539
|
+
context.report({ loc, messageId: 'emptyRule' });
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (isPathDocAnchorTag(entry.tag)) {
|
|
545
|
+
if (!resolvePathAnchor(entry.value, roots)) {
|
|
546
|
+
context.report({
|
|
547
|
+
loc,
|
|
548
|
+
messageId: 'unresolvedPath',
|
|
549
|
+
data: { tag: entry.tag, value: entry.value },
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (entry.tag === 'adr' && !resolveAdrAnchor(entry.value, roots)) {
|
|
556
|
+
context.report({ loc, messageId: 'unresolvedAdr', data: { value: entry.value } });
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const createProteumEslintConfig = ({ docAnchors = 'warn', ignores = [] } = {}) => [
|
|
311
565
|
{
|
|
312
566
|
ignores: [...defaultIgnores, ...ignores],
|
|
313
567
|
},
|
|
@@ -334,6 +588,8 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
|
|
|
334
588
|
rules: {
|
|
335
589
|
'no-app-import': createNoAppImportRule(),
|
|
336
590
|
'no-swallowed-caught-error': createSwallowedErrorRule(),
|
|
591
|
+
'require-doc-anchor': createRequireDocAnchorRule(),
|
|
592
|
+
'valid-doc-anchor': createValidDocAnchorRule(),
|
|
337
593
|
},
|
|
338
594
|
},
|
|
339
595
|
react: reactPlugin,
|
|
@@ -344,6 +600,13 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
|
|
|
344
600
|
'@typescript-eslint/no-explicit-any': 'error',
|
|
345
601
|
'proteum/no-app-import': 'error',
|
|
346
602
|
'proteum/no-swallowed-caught-error': 'error',
|
|
603
|
+
// Missing anchors warn by default so adopting apps see the backlog
|
|
604
|
+
// without a failing build; pass `docAnchors: 'error'` once backfilled.
|
|
605
|
+
'proteum/require-doc-anchor': docAnchors,
|
|
606
|
+
// A stale anchor is always an error: it only fires on files that
|
|
607
|
+
// already opted in, and a pointer to a deleted document is worse
|
|
608
|
+
// than no pointer at all.
|
|
609
|
+
'proteum/valid-doc-anchor': docAnchors === 'off' ? 'off' : 'error',
|
|
347
610
|
'no-restricted-syntax': [
|
|
348
611
|
'error',
|
|
349
612
|
{
|
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.11",
|
|
5
5
|
"author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
|
|
6
6
|
"repository": "git://github.com/gaetanlegac/proteum.git",
|
|
7
7
|
"license": "MIT",
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import { serialize } from 'v8';
|
|
7
7
|
import { formatWithOptions } from 'util';
|
|
8
8
|
import md5 from 'md5';
|
|
9
|
-
import dayjs from 'dayjs';
|
|
10
9
|
import stringify from 'fast-safe-stringify';
|
|
11
10
|
|
|
12
11
|
// Npm
|
|
@@ -178,7 +177,6 @@ export default class Console {
|
|
|
178
177
|
public logger!: Logger<ILogObj>;
|
|
179
178
|
// Buffers
|
|
180
179
|
public logs: TJsonLog[] = [];
|
|
181
|
-
private reported: { [hash: string]: { times: number; last: Date } } = {};
|
|
182
180
|
|
|
183
181
|
/*----------------------------------
|
|
184
182
|
- LIFECYCLE
|
|
@@ -371,24 +369,9 @@ export default class Console {
|
|
|
371
369
|
// Genertae unique error hash
|
|
372
370
|
const hash = md5(inspection.stacktraces[0]);
|
|
373
371
|
|
|
374
|
-
// Don't send the same error twice in a row (avoid email spamming)
|
|
375
|
-
const lastReport = this.reported[hash];
|
|
376
|
-
let isDuplicate = false;
|
|
377
|
-
if (lastReport === undefined) {
|
|
378
|
-
this.reported[hash] = { times: 0, last: new Date() };
|
|
379
|
-
|
|
380
|
-
// If error older than 1 day
|
|
381
|
-
} else if (dayjs(now).diff(dayjs(lastReport.last), 'day') > 1) {
|
|
382
|
-
lastReport.times++;
|
|
383
|
-
lastReport.last = now;
|
|
384
|
-
} else {
|
|
385
|
-
isDuplicate = true;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
372
|
const bugReport: ServerBug = {
|
|
389
373
|
// Context
|
|
390
374
|
hash: hash,
|
|
391
|
-
isDuplicate,
|
|
392
375
|
date: now,
|
|
393
376
|
channelType,
|
|
394
377
|
channelId,
|