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
|
@@ -30,7 +30,13 @@ import type { TServerRouter } from '..';
|
|
|
30
30
|
import type { TDevConsoleLogLevel } from '@common/dev/console';
|
|
31
31
|
import type { TPerfGroupBy } from '@common/dev/performance';
|
|
32
32
|
import type { TServerReadyConnectedProject } from '@common/dev/serverHotReload';
|
|
33
|
-
import
|
|
33
|
+
import {
|
|
34
|
+
devSessionLoginPath,
|
|
35
|
+
devSessionStartPath,
|
|
36
|
+
normalizeDevSessionRedirectPath,
|
|
37
|
+
type TDevSessionStartResponse,
|
|
38
|
+
type TDevSessionUserSummary,
|
|
39
|
+
} from '@common/dev/session';
|
|
34
40
|
import { serverHotReloadMessageType } from '@common/dev/serverHotReload';
|
|
35
41
|
import { explainSectionNames } from '@common/dev/diagnostics';
|
|
36
42
|
import {
|
|
@@ -88,6 +94,16 @@ type TDevSessionAuthService = {
|
|
|
88
94
|
createSession: (session: { email: string }, request: { id: string; res: express.Response }) => string;
|
|
89
95
|
decodeSession: (session: { email: string }, req: express.Request) => Promise<TBasicUser | null>;
|
|
90
96
|
};
|
|
97
|
+
type TDevSessionResult =
|
|
98
|
+
| {
|
|
99
|
+
ok: true;
|
|
100
|
+
response: TDevSessionStartResponse;
|
|
101
|
+
}
|
|
102
|
+
| {
|
|
103
|
+
ok: false;
|
|
104
|
+
error: string;
|
|
105
|
+
statusCode: number;
|
|
106
|
+
};
|
|
91
107
|
|
|
92
108
|
const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPolicyOptions => {
|
|
93
109
|
const directives: TContentSecurityPolicyDirectives = {
|
|
@@ -177,6 +193,77 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
177
193
|
};
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
private getSingleQueryValue(value: express.Request['query'][string]): string {
|
|
197
|
+
if (typeof value === 'string') return value.trim();
|
|
198
|
+
if (Array.isArray(value) && value.length === 1 && typeof value[0] === 'string') return value[0].trim();
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private normalizeDevSessionRedirect(value: express.Request['query'][string]): string {
|
|
203
|
+
try {
|
|
204
|
+
return normalizeDevSessionRedirectPath(this.getSingleQueryValue(value));
|
|
205
|
+
} catch {
|
|
206
|
+
return '';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private isLocalDevSessionRequest(req: express.Request): boolean {
|
|
211
|
+
const hostname = req.hostname.toLowerCase();
|
|
212
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async createDevSessionResponse({
|
|
216
|
+
email,
|
|
217
|
+
req,
|
|
218
|
+
requiredRole,
|
|
219
|
+
res,
|
|
220
|
+
source,
|
|
221
|
+
}: {
|
|
222
|
+
email: string;
|
|
223
|
+
req: express.Request;
|
|
224
|
+
requiredRole: string;
|
|
225
|
+
res: express.Response;
|
|
226
|
+
source: 'login' | 'start';
|
|
227
|
+
}): Promise<TDevSessionResult> {
|
|
228
|
+
if (!email) return { ok: false, statusCode: 400, error: 'Email is required.' };
|
|
229
|
+
|
|
230
|
+
const auth = this.resolveDevSessionAuthService();
|
|
231
|
+
const user = await auth.decodeSession({ email }, req);
|
|
232
|
+
|
|
233
|
+
if (!user) return { ok: false, statusCode: 404, error: `No user could be resolved for "${email}".` };
|
|
234
|
+
if (requiredRole && !user.roles.includes(requiredRole)) {
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
statusCode: 403,
|
|
238
|
+
error: `User "${email}" does not have required role "${requiredRole}".`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const token = auth.createSession(
|
|
243
|
+
{ email },
|
|
244
|
+
{
|
|
245
|
+
id: `proteum-session-${source}:${Date.now()}`,
|
|
246
|
+
res,
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
const issuedAt = new Date().toISOString();
|
|
250
|
+
const expiresAt = new Date(Date.now() + auth.config.jwt.expiration).toISOString();
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
response: {
|
|
255
|
+
user: this.summarizeDevSessionUser(user),
|
|
256
|
+
session: {
|
|
257
|
+
token,
|
|
258
|
+
cookieName: 'authorization',
|
|
259
|
+
expiresInMs: auth.config.jwt.expiration,
|
|
260
|
+
issuedAt,
|
|
261
|
+
expiresAt,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
180
267
|
private async verifyConnectedProjectsBeforeStart(): Promise<TServerReadyConnectedProject[]> {
|
|
181
268
|
const verifiedConnectedProjects: TServerReadyConnectedProject[] = [];
|
|
182
269
|
|
|
@@ -766,50 +853,60 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
766
853
|
}
|
|
767
854
|
});
|
|
768
855
|
|
|
769
|
-
routes.
|
|
770
|
-
|
|
771
|
-
|
|
856
|
+
routes.get(devSessionLoginPath, async (req, res) => {
|
|
857
|
+
if (!this.isLocalDevSessionRequest(req)) {
|
|
858
|
+
res.status(403).send('Proteum session login is only available from localhost.');
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
772
861
|
|
|
773
|
-
|
|
774
|
-
|
|
862
|
+
const email = this.getSingleQueryValue(req.query.email);
|
|
863
|
+
const requiredRole = this.getSingleQueryValue(req.query.role);
|
|
864
|
+
const redirect = this.normalizeDevSessionRedirect(req.query.redirect);
|
|
865
|
+
|
|
866
|
+
if (!redirect) {
|
|
867
|
+
res.status(400).send('Redirect must be a local absolute path.');
|
|
775
868
|
return;
|
|
776
869
|
}
|
|
777
870
|
|
|
778
871
|
try {
|
|
779
|
-
const
|
|
780
|
-
|
|
872
|
+
const result = await this.createDevSessionResponse({
|
|
873
|
+
email,
|
|
874
|
+
req,
|
|
875
|
+
requiredRole,
|
|
876
|
+
res,
|
|
877
|
+
source: 'login',
|
|
878
|
+
});
|
|
781
879
|
|
|
782
|
-
if (!
|
|
783
|
-
res.status(
|
|
880
|
+
if (!result.ok) {
|
|
881
|
+
res.status(result.statusCode).send(result.error);
|
|
784
882
|
return;
|
|
785
883
|
}
|
|
786
884
|
|
|
787
|
-
|
|
788
|
-
|
|
885
|
+
res.redirect(302, redirect);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
res.status(500).send(error instanceof Error ? error.message : String(error));
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
routes.post(devSessionStartPath, async (req, res) => {
|
|
892
|
+
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : '';
|
|
893
|
+
const requiredRole = typeof req.body?.role === 'string' ? req.body.role.trim() : '';
|
|
894
|
+
|
|
895
|
+
try {
|
|
896
|
+
const result = await this.createDevSessionResponse({
|
|
897
|
+
email,
|
|
898
|
+
req,
|
|
899
|
+
requiredRole,
|
|
900
|
+
res,
|
|
901
|
+
source: 'start',
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
if (!result.ok) {
|
|
905
|
+
res.status(result.statusCode).json({ error: result.error });
|
|
789
906
|
return;
|
|
790
907
|
}
|
|
791
908
|
|
|
792
|
-
|
|
793
|
-
{ email },
|
|
794
|
-
{
|
|
795
|
-
id: `proteum-session:${Date.now()}`,
|
|
796
|
-
res,
|
|
797
|
-
},
|
|
798
|
-
);
|
|
799
|
-
const issuedAt = new Date().toISOString();
|
|
800
|
-
const expiresAt = new Date(Date.now() + auth.config.jwt.expiration).toISOString();
|
|
801
|
-
const response: TDevSessionStartResponse = {
|
|
802
|
-
user: this.summarizeDevSessionUser(user),
|
|
803
|
-
session: {
|
|
804
|
-
token,
|
|
805
|
-
cookieName: 'authorization',
|
|
806
|
-
expiresInMs: auth.config.jwt.expiration,
|
|
807
|
-
issuedAt,
|
|
808
|
-
expiresAt,
|
|
809
|
-
},
|
|
810
|
-
};
|
|
811
|
-
|
|
812
|
-
res.json(response);
|
|
909
|
+
res.json(result.response);
|
|
813
910
|
} catch (error) {
|
|
814
911
|
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
815
912
|
}
|
|
@@ -108,10 +108,6 @@ test('project instruction sources require unit tests for applicable production c
|
|
|
108
108
|
fs.readFileSync(path.join(projectAgentsRoot, 'AGENTS.md'), 'utf8'),
|
|
109
109
|
/production changes must always add or update focused unit tests/,
|
|
110
110
|
);
|
|
111
|
-
assert.match(
|
|
112
|
-
fs.readFileSync(path.join(projectAgentsRoot, 'root', 'AGENTS.md'), 'utf8'),
|
|
113
|
-
/always add or update focused unit tests/,
|
|
114
|
-
);
|
|
115
111
|
assert.match(
|
|
116
112
|
fs.readFileSync(path.join(projectAgentsRoot, 'tests', 'AGENTS.md'), 'utf8'),
|
|
117
113
|
/For every production change, add or update focused unit tests/,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const coreRoot = path.resolve(__dirname, '..');
|
|
5
|
+
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
|
|
6
|
+
process.env.TS_NODE_TRANSPILE_ONLY = '1';
|
|
7
|
+
require('ts-node/register/transpile-only');
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
buildDevSessionLoginUrl,
|
|
11
|
+
normalizeDevSessionRedirectPath,
|
|
12
|
+
} = require('../common/dev/session.ts');
|
|
13
|
+
|
|
14
|
+
test('buildDevSessionLoginUrl creates a local browser handoff URL', () => {
|
|
15
|
+
const url = buildDevSessionLoginUrl({
|
|
16
|
+
baseUrl: 'http://127.0.0.1:3020/',
|
|
17
|
+
email: 'contact@gaetan-legac.fr',
|
|
18
|
+
redirect: '/projects/project-123?tab=radar',
|
|
19
|
+
role: 'GOD',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
assert.equal(
|
|
23
|
+
url,
|
|
24
|
+
'http://127.0.0.1:3020/__proteum/session/login?email=contact%40gaetan-legac.fr&redirect=%2Fprojects%2Fproject-123%3Ftab%3Dradar&role=GOD',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('normalizeDevSessionRedirectPath rejects external redirects', () => {
|
|
29
|
+
assert.throws(() => normalizeDevSessionRedirectPath('https://example.com'), /local absolute path/);
|
|
30
|
+
assert.throws(() => normalizeDevSessionRedirectPath('//example.com'), /local absolute path/);
|
|
31
|
+
assert.throws(() => normalizeDevSessionRedirectPath('/\nadmin'), /local absolute path/);
|
|
32
|
+
assert.equal(normalizeDevSessionRedirectPath(''), '/');
|
|
33
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
buildDocAnchorGroups,
|
|
5
|
+
collectDocAnchors,
|
|
6
|
+
hasDocAnchorTag,
|
|
7
|
+
isPathDocAnchorTag,
|
|
8
|
+
parseDocAnchorComment,
|
|
9
|
+
} = require('../docAnchors.js');
|
|
10
|
+
|
|
11
|
+
test('doc anchor parser reads every supported tag from a block comment', () => {
|
|
12
|
+
const groups = collectDocAnchors(`
|
|
13
|
+
/**
|
|
14
|
+
* @docs docs/features/search
|
|
15
|
+
* @adr ADR-0004
|
|
16
|
+
* @fix docs/fixes/2026-06-09-keyword-search-semantic-order.md
|
|
17
|
+
* @rule Composite ordering stays alias-aware.
|
|
18
|
+
*/
|
|
19
|
+
export default definePageRoute({ path: '/browse' });
|
|
20
|
+
`);
|
|
21
|
+
|
|
22
|
+
assert.deepEqual(groups.docs, ['docs/features/search']);
|
|
23
|
+
assert.deepEqual(groups.adr, ['ADR-0004']);
|
|
24
|
+
assert.deepEqual(groups.fix, ['docs/fixes/2026-06-09-keyword-search-semantic-order.md']);
|
|
25
|
+
assert.deepEqual(groups.rules, ['Composite ordering stays alias-aware.']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('doc anchor parser joins multi-line rule invariants into one value', () => {
|
|
29
|
+
const groups = collectDocAnchors(`
|
|
30
|
+
/**
|
|
31
|
+
* @rule Composite ordering stays alias-aware.
|
|
32
|
+
* Never rewrite ORDER BY with regex.
|
|
33
|
+
*/
|
|
34
|
+
`);
|
|
35
|
+
|
|
36
|
+
assert.deepEqual(groups.rules, ['Composite ordering stays alias-aware. Never rewrite ORDER BY with regex.']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('doc anchor parser reports the line each anchor sits on', () => {
|
|
40
|
+
const groups = collectDocAnchors(['const a = 1;', '', '/**', ' * @docs docs/features/search', ' */'].join('\n'));
|
|
41
|
+
|
|
42
|
+
assert.equal(groups.entries.length, 1);
|
|
43
|
+
assert.equal(groups.entries[0].line, 4);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('doc anchor parser ignores unrelated jsdoc tags', () => {
|
|
47
|
+
const groups = collectDocAnchors(`
|
|
48
|
+
/**
|
|
49
|
+
* @param input The request payload.
|
|
50
|
+
* @returns The parsed row.
|
|
51
|
+
*/
|
|
52
|
+
`);
|
|
53
|
+
|
|
54
|
+
assert.equal(groups.entries.length, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('doc anchor parser stops a value at a blank comment line', () => {
|
|
58
|
+
const groups = collectDocAnchors(`
|
|
59
|
+
/**
|
|
60
|
+
* @rule Never rewrite ORDER BY with regex.
|
|
61
|
+
*
|
|
62
|
+
* Unrelated prose that must not join the invariant.
|
|
63
|
+
*/
|
|
64
|
+
`);
|
|
65
|
+
|
|
66
|
+
assert.deepEqual(groups.rules, ['Never rewrite ORDER BY with regex.']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('doc anchor parser collects anchors from several comments and drops duplicates', () => {
|
|
70
|
+
const groups = collectDocAnchors(`
|
|
71
|
+
/** @docs docs/features/search */
|
|
72
|
+
const first = 1;
|
|
73
|
+
/** @docs docs/features/search */
|
|
74
|
+
/** @docs docs/features/billing */
|
|
75
|
+
const second = 2;
|
|
76
|
+
`);
|
|
77
|
+
|
|
78
|
+
assert.deepEqual(groups.docs, ['docs/features/search', 'docs/features/billing']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('doc anchor parser ignores tags with no value', () => {
|
|
82
|
+
const groups = collectDocAnchors(`
|
|
83
|
+
/**
|
|
84
|
+
* @docs
|
|
85
|
+
* @rule
|
|
86
|
+
*/
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
assert.equal(groups.entries.length, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('doc anchor parser caps how much source it scans', () => {
|
|
93
|
+
const padding = `${'// filler\n'.repeat(200)}`;
|
|
94
|
+
const source = `${padding}/** @docs docs/features/search */`;
|
|
95
|
+
|
|
96
|
+
assert.equal(collectDocAnchors(source, { maxLength: 50 }).entries.length, 0);
|
|
97
|
+
assert.equal(collectDocAnchors(source).docs.length, 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('doc anchor parser tolerates empty and missing input', () => {
|
|
101
|
+
assert.equal(collectDocAnchors('').entries.length, 0);
|
|
102
|
+
assert.equal(collectDocAnchors(undefined).entries.length, 0);
|
|
103
|
+
assert.equal(parseDocAnchorComment(undefined).length, 0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('doc anchor helpers expose the tag contract', () => {
|
|
107
|
+
const groups = buildDocAnchorGroups(parseDocAnchorComment('* @docs docs/features/search', 1));
|
|
108
|
+
|
|
109
|
+
assert.equal(hasDocAnchorTag(groups, 'docs'), true);
|
|
110
|
+
assert.equal(hasDocAnchorTag(groups, 'rule'), false);
|
|
111
|
+
assert.equal(isPathDocAnchorTag('docs'), true);
|
|
112
|
+
assert.equal(isPathDocAnchorTag('fix'), true);
|
|
113
|
+
assert.equal(isPathDocAnchorTag('adr'), false);
|
|
114
|
+
assert.equal(isPathDocAnchorTag('rule'), false);
|
|
115
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const coreRoot = path.resolve(__dirname, '..');
|
|
7
|
+
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
|
|
8
|
+
process.env.TS_NODE_TRANSPILE_ONLY = '1';
|
|
9
|
+
require('ts-node/register/transpile-only');
|
|
10
|
+
require('../cli/context.ts');
|
|
11
|
+
|
|
12
|
+
const { buildDocsCheckReport } = require('../cli/commands/docs.ts');
|
|
13
|
+
|
|
14
|
+
const createRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-docs-check-'));
|
|
15
|
+
|
|
16
|
+
const writeFile = (root, filepath, content) => {
|
|
17
|
+
const full = path.join(root, filepath);
|
|
18
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
19
|
+
fs.writeFileSync(full, content);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const kinds = (report, kind) => report.findings.filter((finding) => finding.kind === kind);
|
|
23
|
+
|
|
24
|
+
test('docs check accepts anchors that resolve against the corpus', () => {
|
|
25
|
+
const root = createRoot();
|
|
26
|
+
writeFile(root, 'docs/features/search/README.md', '# Search\n');
|
|
27
|
+
writeFile(
|
|
28
|
+
root,
|
|
29
|
+
'apps/product/server/controllers/search.ts',
|
|
30
|
+
'/**\n * @docs docs/features/search\n */\nexport default {};\n',
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const report = buildDocsCheckReport(root);
|
|
34
|
+
|
|
35
|
+
assert.equal(kinds(report, 'unresolved-anchor').length, 0);
|
|
36
|
+
assert.equal(kinds(report, 'orphan-feature-pack').length, 0);
|
|
37
|
+
assert.equal(report.anchoredFiles, 1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('docs check reports an anchor pointing at a deleted document', () => {
|
|
41
|
+
const root = createRoot();
|
|
42
|
+
writeFile(root, 'docs/features/search/README.md', '# Search\n');
|
|
43
|
+
writeFile(
|
|
44
|
+
root,
|
|
45
|
+
'apps/product/server/controllers/search.ts',
|
|
46
|
+
'/**\n * @docs docs/features/deleted\n */\nexport default {};\n',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const report = buildDocsCheckReport(root);
|
|
50
|
+
const unresolved = kinds(report, 'unresolved-anchor');
|
|
51
|
+
|
|
52
|
+
assert.equal(unresolved.length, 1);
|
|
53
|
+
assert.equal(unresolved[0].detail, '@docs docs/features/deleted');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('docs check resolves anchors from an app that owns a nested docs directory', () => {
|
|
57
|
+
const root = createRoot();
|
|
58
|
+
writeFile(root, 'docs/features/search/README.md', '# Search\n');
|
|
59
|
+
writeFile(root, 'apps/product/docs/fixes/local.md', '# Local\n');
|
|
60
|
+
writeFile(
|
|
61
|
+
root,
|
|
62
|
+
'apps/product/server/controllers/search.ts',
|
|
63
|
+
'/**\n * @docs docs/features/search\n */\nexport default {};\n',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
assert.equal(kinds(buildDocsCheckReport(root), 'unresolved-anchor').length, 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('docs check resolves anchors aimed at the repo corpus when run from an app root', () => {
|
|
70
|
+
const root = createRoot();
|
|
71
|
+
writeFile(root, 'docs/features/search/README.md', '# Search\n');
|
|
72
|
+
writeFile(
|
|
73
|
+
root,
|
|
74
|
+
'apps/product/server/controllers/search.ts',
|
|
75
|
+
'/**\n * @docs docs/features/search\n */\nexport default {};\n',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Running from the app root must not orphan an anchor that points at the
|
|
79
|
+
// repository-level corpus one directory up.
|
|
80
|
+
const report = buildDocsCheckReport(path.join(root, 'apps', 'product'));
|
|
81
|
+
|
|
82
|
+
assert.equal(kinds(report, 'unresolved-anchor').length, 0);
|
|
83
|
+
assert.equal(report.anchoredFiles, 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('docs check reports a fix note whose invariant no code anchors', () => {
|
|
87
|
+
const root = createRoot();
|
|
88
|
+
writeFile(root, 'docs/fixes/2026-06-14-watchdog.md', '# Fix\n\n## Agent warning\n\nDo not remove the guards.\n');
|
|
89
|
+
writeFile(root, 'apps/daemon/src/rdap.ts', 'export default {};\n');
|
|
90
|
+
|
|
91
|
+
const unanchored = kinds(buildDocsCheckReport(root), 'unanchored-fix-note');
|
|
92
|
+
|
|
93
|
+
assert.equal(unanchored.length, 1);
|
|
94
|
+
assert.equal(unanchored[0].subject, 'docs/fixes/2026-06-14-watchdog.md');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('docs check clears a fix note once code anchors it', () => {
|
|
98
|
+
const root = createRoot();
|
|
99
|
+
writeFile(root, 'docs/fixes/2026-06-14-watchdog.md', '# Fix\n\n## Agent warning\n\nDo not remove the guards.\n');
|
|
100
|
+
writeFile(
|
|
101
|
+
root,
|
|
102
|
+
'apps/daemon/src/rdap.ts',
|
|
103
|
+
'/**\n * @fix docs/fixes/2026-06-14-watchdog.md\n * @rule Do not remove the guards.\n */\nexport default {};\n',
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
assert.equal(kinds(buildDocsCheckReport(root), 'unanchored-fix-note').length, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('docs check reports a feature pack that no code points at', () => {
|
|
110
|
+
const root = createRoot();
|
|
111
|
+
writeFile(root, 'docs/features/search/README.md', '# Search\n');
|
|
112
|
+
writeFile(root, 'docs/features/orphan/README.md', '# Orphan\n');
|
|
113
|
+
writeFile(
|
|
114
|
+
root,
|
|
115
|
+
'apps/product/server/controllers/search.ts',
|
|
116
|
+
'/**\n * @docs docs/features/search\n */\nexport default {};\n',
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const orphans = kinds(buildDocsCheckReport(root), 'orphan-feature-pack');
|
|
120
|
+
|
|
121
|
+
assert.deepEqual(orphans.map((finding) => finding.subject), ['docs/features/orphan']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('docs check ignores generated and vendored directories', () => {
|
|
125
|
+
const root = createRoot();
|
|
126
|
+
writeFile(root, 'docs/features/search/README.md', '# Search\n');
|
|
127
|
+
writeFile(
|
|
128
|
+
root,
|
|
129
|
+
'node_modules/some-package/index.ts',
|
|
130
|
+
'/**\n * @docs docs/features/nope\n */\nexport default {};\n',
|
|
131
|
+
);
|
|
132
|
+
writeFile(root, 'apps/product/var/generated.ts', '/**\n * @docs docs/features/nope\n */\nexport default {};\n');
|
|
133
|
+
|
|
134
|
+
const report = buildDocsCheckReport(root);
|
|
135
|
+
|
|
136
|
+
assert.equal(kinds(report, 'unresolved-anchor').length, 0);
|
|
137
|
+
assert.equal(report.anchoredFiles, 0);
|
|
138
|
+
});
|