@rigour-labs/cli 6.7.10 → 6.8.1-rc.1
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/dist/cli.js +43 -0
- package/dist/commands/brief.d.ts +29 -0
- package/dist/commands/brief.js +169 -0
- package/dist/commands/hooks-push.js +2 -1
- package/dist/commands/hooks-stop.js +2 -1
- package/dist/commands/hooks.d.ts +4 -0
- package/dist/commands/hooks.js +32 -7
- package/dist/commands/thread.d.ts +3 -0
- package/dist/commands/thread.js +26 -0
- package/package.json +7 -4
- package/dist/cli-help.test.d.ts +0 -1
- package/dist/cli-help.test.js +0 -27
- package/dist/commands/deep-provider.test.d.ts +0 -1
- package/dist/commands/deep-provider.test.js +0 -19
- package/dist/commands/deep.test.d.ts +0 -1
- package/dist/commands/deep.test.js +0 -46
- package/dist/commands/demo.test.d.ts +0 -1
- package/dist/commands/demo.test.js +0 -59
- package/dist/commands/doctor.test.d.ts +0 -1
- package/dist/commands/doctor.test.js +0 -20
- package/dist/commands/exit-codes.test.d.ts +0 -1
- package/dist/commands/exit-codes.test.js +0 -20
- package/dist/commands/export-training-sites.test.d.ts +0 -1
- package/dist/commands/export-training-sites.test.js +0 -25
- package/dist/commands/hooks-check-repos.test.d.ts +0 -1
- package/dist/commands/hooks-check-repos.test.js +0 -33
- package/dist/commands/hooks-git.test.d.ts +0 -1
- package/dist/commands/hooks-git.test.js +0 -64
- package/dist/commands/hooks-push.test.d.ts +0 -1
- package/dist/commands/hooks-push.test.js +0 -86
- package/dist/commands/hooks-stop.test.d.ts +0 -1
- package/dist/commands/hooks-stop.test.js +0 -165
- package/dist/commands/hooks.test.d.ts +0 -1
- package/dist/commands/hooks.test.js +0 -247
- package/dist/commands/init-mcp-spec.test.d.ts +0 -1
- package/dist/commands/init-mcp-spec.test.js +0 -10
- package/dist/commands/init.test.d.ts +0 -1
- package/dist/commands/init.test.js +0 -107
- package/dist/commands/learn.test.d.ts +0 -1
- package/dist/commands/learn.test.js +0 -50
- package/dist/commands/personal.test.d.ts +0 -1
- package/dist/commands/personal.test.js +0 -191
- package/dist/commands/repo-setup.test.d.ts +0 -1
- package/dist/commands/repo-setup.test.js +0 -47
- package/dist/commands/review-checked.test.d.ts +0 -1
- package/dist/commands/review-checked.test.js +0 -50
- package/dist/commands/review-diff-tests.test.d.ts +0 -1
- package/dist/commands/review-diff-tests.test.js +0 -20
- package/dist/commands/review-human.test.d.ts +0 -1
- package/dist/commands/review-human.test.js +0 -94
- package/dist/commands/review-post.test.d.ts +0 -1
- package/dist/commands/review-post.test.js +0 -157
- package/dist/commands/review-pr-body.test.d.ts +0 -1
- package/dist/commands/review-pr-body.test.js +0 -19
- package/dist/commands/review-receipt.test.d.ts +0 -1
- package/dist/commands/review-receipt.test.js +0 -16
- package/dist/commands/review-reviewer.test.d.ts +0 -1
- package/dist/commands/review-reviewer.test.js +0 -68
- package/dist/commands/review-scope.test.d.ts +0 -1
- package/dist/commands/review-scope.test.js +0 -43
- package/dist/commands/review-stats.test.d.ts +0 -1
- package/dist/commands/review-stats.test.js +0 -19
- package/dist/commands/review-stdin.test.d.ts +0 -1
- package/dist/commands/review-stdin.test.js +0 -21
- package/dist/commands/review-summary.test.d.ts +0 -1
- package/dist/commands/review-summary.test.js +0 -46
- package/dist/commands/review-task.test.d.ts +0 -1
- package/dist/commands/review-task.test.js +0 -47
- package/dist/commands/scan-rules.test.d.ts +0 -1
- package/dist/commands/scan-rules.test.js +0 -29
- package/dist/commands/scan.test.d.ts +0 -1
- package/dist/commands/scan.test.js +0 -64
- package/dist/commands/skills.test.d.ts +0 -1
- package/dist/commands/skills.test.js +0 -56
- package/dist/commands/studio-activity.test.d.ts +0 -1
- package/dist/commands/studio-activity.test.js +0 -47
- package/dist/commands/studio-checkouts.test.d.ts +0 -1
- package/dist/commands/studio-checkouts.test.js +0 -23
- package/dist/commands/studio-context.test.d.ts +0 -1
- package/dist/commands/studio-context.test.js +0 -26
- package/dist/commands/studio-contracts.test.d.ts +0 -1
- package/dist/commands/studio-contracts.test.js +0 -8
- package/dist/commands/studio-guard.test.d.ts +0 -1
- package/dist/commands/studio-guard.test.js +0 -24
- package/dist/commands/studio-learned-rules.test.d.ts +0 -1
- package/dist/commands/studio-learned-rules.test.js +0 -31
- package/dist/commands/studio-learning.test.d.ts +0 -1
- package/dist/commands/studio-learning.test.js +0 -41
- package/dist/commands/studio-progress.test.d.ts +0 -1
- package/dist/commands/studio-progress.test.js +0 -41
- package/dist/commands/studio-reviewer.test.d.ts +0 -1
- package/dist/commands/studio-reviewer.test.js +0 -92
- package/dist/commands/studio-week.test.d.ts +0 -1
- package/dist/commands/studio-week.test.js +0 -32
- package/dist/commands/uninstall.test.d.ts +0 -1
- package/dist/commands/uninstall.test.js +0 -104
- package/dist/init-rules.test.d.ts +0 -1
- package/dist/init-rules.test.js +0 -55
- package/dist/smoke.test.d.ts +0 -1
- package/dist/smoke.test.js +0 -82
- package/dist/utils/cli-version.test.d.ts +0 -1
- package/dist/utils/cli-version.test.js +0 -9
- package/dist/utils/version.test.d.ts +0 -1
- package/dist/utils/version.test.js +0 -22
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { splitByChangedLines } from '@rigour-labs/core';
|
|
3
|
-
import { buildCiReviewSummary, renderGithubSummary } from './review-summary.js';
|
|
4
|
-
function failure(id, severity, file, line, details) {
|
|
5
|
-
return { id, title: id, details, hint: details, severity, provenance: 'traditional', files: [file], line };
|
|
6
|
-
}
|
|
7
|
-
describe('GitHub change review summary', () => {
|
|
8
|
-
it('prioritizes severe findings and never includes raw secret-bearing messages', () => {
|
|
9
|
-
const failures = [
|
|
10
|
-
failure('style-drift', 'low', 'src/a.ts', 3, 'ordinary style'),
|
|
11
|
-
failure('security-patterns', 'critical', 'src/shell.ts', 8, 'API_KEY=secret-value-that-must-not-appear'),
|
|
12
|
-
failure('logic-drift', 'medium', 'src/logic.ts', 5, 'changed operator'),
|
|
13
|
-
];
|
|
14
|
-
const summary = buildCiReviewSummary(failures, 5, { 'src/a.ts': new Set([3]), 'src/shell.ts': new Set([8]) });
|
|
15
|
-
const markdown = renderGithubSummary(summary);
|
|
16
|
-
expect(summary.findings.map(item => item.rule)).toEqual(['security-patterns', 'logic-drift', 'style-drift']);
|
|
17
|
-
expect(summary.severity).toEqual({ critical: 1, high: 0, medium: 1, low: 1 });
|
|
18
|
-
expect(summary.excluded_outside_changed_lines).toBe(2);
|
|
19
|
-
expect(summary.changed_lines).toBe(2);
|
|
20
|
-
expect(markdown).toContain('Trace the input and sink');
|
|
21
|
-
expect(markdown).not.toContain('secret-value-that-must-not-appear');
|
|
22
|
-
});
|
|
23
|
-
it('caps output and escapes untrusted path text', () => {
|
|
24
|
-
const failures = Array.from({ length: 8 }, (_, i) => failure('style-drift', 'low', `src/bad\`\n${i}.ts`, i + 1, 'untrusted'));
|
|
25
|
-
const summary = buildCiReviewSummary(failures, 8, {});
|
|
26
|
-
const markdown = renderGithubSummary(summary);
|
|
27
|
-
expect(summary.findings).toHaveLength(5);
|
|
28
|
-
expect(summary.truncated).toBe(3);
|
|
29
|
-
expect(markdown).not.toContain('bad`');
|
|
30
|
-
expect(markdown).toContain('3 more finding(s)');
|
|
31
|
-
});
|
|
32
|
-
it('does not imply a clean repository when changed lines pass', () => {
|
|
33
|
-
const markdown = renderGithubSummary(buildCiReviewSummary([], 20, {}));
|
|
34
|
-
expect(markdown).toContain('No findings on changed lines');
|
|
35
|
-
expect(markdown).toContain('does not assess the full repository');
|
|
36
|
-
});
|
|
37
|
-
it('abstains from attributing file-level findings to changed line one', () => {
|
|
38
|
-
const withLine = failure('security-patterns', 'high', 'src/a.ts', 5, 'specific line');
|
|
39
|
-
const fileLevel = { ...failure('AST_COMPLEXITY', 'medium', 'src/a.ts', 1, 'whole file'), line: undefined };
|
|
40
|
-
const split = splitByChangedLines([withLine, fileLevel], { 'src/a.ts': new Set([1, 5]) });
|
|
41
|
-
expect(split.findings).toEqual([withLine]);
|
|
42
|
-
expect(split.fileFindings).toEqual([fileLevel]);
|
|
43
|
-
const summary = buildCiReviewSummary(split.findings, 2, { 'src/a.ts': new Set([1, 5]) }, split.fileFindings.length + split.unlocated);
|
|
44
|
-
expect(renderGithubSummary(summary)).toContain('file-level finding(s) lacked a changed-line location');
|
|
45
|
-
});
|
|
46
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'child_process';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
-
import { reviewAckCommand, reviewExportCommand, reviewTaskCommand } from './review-task.js';
|
|
7
|
-
let repo;
|
|
8
|
-
let out;
|
|
9
|
-
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-review-task-'));
|
|
12
|
-
git('init', '-q', '-b', 'main');
|
|
13
|
-
git('config', 'user.email', 't@example.com');
|
|
14
|
-
git('config', 'user.name', 't');
|
|
15
|
-
git('config', 'commit.gpgsign', 'false');
|
|
16
|
-
fs.writeFileSync(path.join(repo, 'README.md'), 'x\n');
|
|
17
|
-
git('add', '-A');
|
|
18
|
-
git('commit', '-qm', 'init');
|
|
19
|
-
fs.writeFileSync(path.join(repo, 'sync.ts'), "export async function syncOrders(db, rows) {\n await db.from('orders').upsert(rows);\n}\n");
|
|
20
|
-
out = [];
|
|
21
|
-
vi.spyOn(console, 'log').mockImplementation((...args) => { out.push(args.join(' ')); });
|
|
22
|
-
vi.spyOn(console, 'error').mockImplementation((...args) => { out.push(args.join(' ')); });
|
|
23
|
-
});
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
vi.restoreAllMocks();
|
|
26
|
-
process.exitCode = undefined;
|
|
27
|
-
fs.rmSync(repo, { recursive: true, force: true });
|
|
28
|
-
});
|
|
29
|
-
describe('review-task, review-ack, review-export', () => {
|
|
30
|
-
it('lists the risky function, records a human review, and exports it', async () => {
|
|
31
|
-
await reviewTaskCommand(repo, { json: true });
|
|
32
|
-
expect(JSON.parse(out.join('\n')).items).toEqual([expect.objectContaining({ file: 'sync.ts', function: 'syncOrders' })]);
|
|
33
|
-
reviewAckCommand(repo, 'sync.ts', 'syncOrders', { verdict: 'no_issue', note: 'rows are one page; key is the order id' });
|
|
34
|
-
expect(out.at(-1)).toContain('Recorded: sync.ts syncOrders (no_issue)');
|
|
35
|
-
out = [];
|
|
36
|
-
await reviewTaskCommand(repo, {});
|
|
37
|
-
expect(out.join('\n')).toContain('No risky changed function waiting for review (1 already reviewed)');
|
|
38
|
-
reviewExportCommand(repo);
|
|
39
|
-
const exported = JSON.parse(fs.readFileSync(path.join(repo, '.rigour', 'reviewed.json'), 'utf8'));
|
|
40
|
-
expect(exported.entries).toEqual([expect.objectContaining({ file: 'sync.ts', function: 'syncOrders', reviewer: 'human', verdict: 'no_issue' })]);
|
|
41
|
-
});
|
|
42
|
-
it('rejects an acknowledgement without a real note, with a failing exit code', () => {
|
|
43
|
-
reviewAckCommand(repo, 'sync.ts', 'syncOrders', { verdict: 'no_issue', note: 'ok' });
|
|
44
|
-
expect(out.at(-1)).toContain('Not recorded: note must say what was checked');
|
|
45
|
-
expect(process.exitCode).toBe(1);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
import { scanRulesCommand } from './scan-rules.js';
|
|
6
|
-
describe('rigour scan-rules', () => {
|
|
7
|
-
let dir;
|
|
8
|
-
afterEach(() => { if (dir)
|
|
9
|
-
fs.rmSync(dir, { recursive: true, force: true }); dir = undefined; vi.restoreAllMocks(); });
|
|
10
|
-
function scan(files, rules) {
|
|
11
|
-
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-rules-'));
|
|
12
|
-
for (const [name, text] of Object.entries(files))
|
|
13
|
-
fs.writeFileSync(path.join(dir, name), text);
|
|
14
|
-
const out = [];
|
|
15
|
-
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { out.push(String(chunk)); return true; });
|
|
16
|
-
scanRulesCommand(dir, Object.keys(files).filter(f => f.endsWith('.ts')), { rules });
|
|
17
|
-
return out.join('').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
18
|
-
}
|
|
19
|
-
it('runs only the named rules and prints one JSON line per finding', () => {
|
|
20
|
-
const code = "export function setup(fn: () => void) {\n window.addEventListener('a', fn);\n addEventListener('b', fn);\n}\n"
|
|
21
|
-
+ "// @ts-expect-error internal\nexport const r = (e: any) => e[Symbol.for('lib.internal.x')];\n";
|
|
22
|
-
expect(scan({ 'a.ts': code }, 'env/bare-browser-global')).toEqual([
|
|
23
|
-
{ rule: 'env/bare-browser-global', file: 'a.ts', line: 3, severity: 'medium', message: expect.stringContaining('addEventListener') },
|
|
24
|
-
]);
|
|
25
|
-
});
|
|
26
|
-
it('rejects an unknown rule id rather than scanning nothing', () => {
|
|
27
|
-
expect(() => scan({ 'a.ts': 'export {};\n' }, 'no/such-rule')).toThrow('Unknown rule(s): no/such-rule');
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import fs from 'fs-extra';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import os from 'os';
|
|
5
|
-
import { scanCommand } from './scan.js';
|
|
6
|
-
describe('scanCommand', () => {
|
|
7
|
-
let testDir;
|
|
8
|
-
beforeEach(async () => {
|
|
9
|
-
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-scan-test-'));
|
|
10
|
-
vi.spyOn(process, 'exit').mockImplementation(() => undefined);
|
|
11
|
-
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
12
|
-
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
13
|
-
});
|
|
14
|
-
afterEach(async () => {
|
|
15
|
-
await fs.remove(testDir);
|
|
16
|
-
vi.restoreAllMocks();
|
|
17
|
-
});
|
|
18
|
-
it('runs in zero-config mode without rigour.yml', async () => {
|
|
19
|
-
await fs.writeJson(path.join(testDir, 'package.json'), { name: 'scan-zero-config-test' });
|
|
20
|
-
await fs.writeFile(path.join(testDir, 'index.js'), "import fake from 'totally-fake-package';\nconsole.log(fake);\n");
|
|
21
|
-
await expect(scanCommand(testDir, [], {})).resolves.not.toThrow();
|
|
22
|
-
expect(process.exit).toHaveBeenCalled();
|
|
23
|
-
expect(await fs.pathExists(path.join(testDir, 'rigour-report.json'))).toBe(true);
|
|
24
|
-
}, 30_000);
|
|
25
|
-
it('uses provided config path when passed', async () => {
|
|
26
|
-
await fs.writeFile(path.join(testDir, 'app.js'), "export const ok = 42;\n");
|
|
27
|
-
await fs.writeFile(path.join(testDir, 'scan-config.yml'), `
|
|
28
|
-
version: 1
|
|
29
|
-
gates:
|
|
30
|
-
required_files: []
|
|
31
|
-
forbid_todos: false
|
|
32
|
-
forbid_fixme: false
|
|
33
|
-
context:
|
|
34
|
-
enabled: false
|
|
35
|
-
environment:
|
|
36
|
-
enabled: false
|
|
37
|
-
retry_loop_breaker:
|
|
38
|
-
enabled: false
|
|
39
|
-
security:
|
|
40
|
-
enabled: false
|
|
41
|
-
duplication_drift:
|
|
42
|
-
enabled: false
|
|
43
|
-
hallucinated_imports:
|
|
44
|
-
enabled: false
|
|
45
|
-
inconsistent_error_handling:
|
|
46
|
-
enabled: false
|
|
47
|
-
context_window_artifacts:
|
|
48
|
-
enabled: false
|
|
49
|
-
promise_safety:
|
|
50
|
-
enabled: false
|
|
51
|
-
phantom_apis:
|
|
52
|
-
enabled: false
|
|
53
|
-
deprecated_apis:
|
|
54
|
-
enabled: false
|
|
55
|
-
test_quality:
|
|
56
|
-
enabled: false
|
|
57
|
-
output:
|
|
58
|
-
report_path: scan-report.json
|
|
59
|
-
`);
|
|
60
|
-
await expect(scanCommand(testDir, [], { config: 'scan-config.yml' })).resolves.not.toThrow();
|
|
61
|
-
expect(process.exit).toHaveBeenCalledWith(0);
|
|
62
|
-
expect(await fs.pathExists(path.join(testDir, 'scan-report.json'))).toBe(true);
|
|
63
|
-
}, 30_000);
|
|
64
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import { installSkills, skillsDoctor } from './skills.js';
|
|
6
|
-
describe('Rigour skills', () => {
|
|
7
|
-
let testDir;
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-skills-'));
|
|
10
|
-
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
11
|
-
});
|
|
12
|
-
afterEach(() => {
|
|
13
|
-
fs.rmSync(testDir, { recursive: true, force: true });
|
|
14
|
-
vi.restoreAllMocks();
|
|
15
|
-
});
|
|
16
|
-
it('installs native Codex skills with valid discovery metadata', async () => {
|
|
17
|
-
await installSkills(testDir, ['context'], { target: 'codex' });
|
|
18
|
-
const filePath = path.join(testDir, '.agents', 'skills', 'rigour-context', 'SKILL.md');
|
|
19
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
20
|
-
expect(content).toContain('name: rigour-context');
|
|
21
|
-
expect(content).toContain('rigour_context_scope');
|
|
22
|
-
expect(content).toContain('repository-wide scan');
|
|
23
|
-
});
|
|
24
|
-
it('installs focused Cursor commands instead of a global rule', async () => {
|
|
25
|
-
await installSkills(testDir, ['verify'], { target: 'cursor' });
|
|
26
|
-
const filePath = path.join(testDir, '.cursor', 'commands', 'rigour-verify.md');
|
|
27
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
28
|
-
expect(content).toContain('rigour_check');
|
|
29
|
-
expect(content).toContain('rigour_get_fix_packet');
|
|
30
|
-
expect(content).not.toContain('alwaysApply');
|
|
31
|
-
});
|
|
32
|
-
it('keeps an existing playbook unless force is requested', async () => {
|
|
33
|
-
const filePath = path.join(testDir, '.cursor', 'commands', 'rigour-context.md');
|
|
34
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
35
|
-
fs.writeFileSync(filePath, 'team customisation');
|
|
36
|
-
await installSkills(testDir, ['context'], { target: 'cursor' });
|
|
37
|
-
expect(fs.readFileSync(filePath, 'utf-8')).toBe('team customisation');
|
|
38
|
-
await installSkills(testDir, ['context'], { target: 'cursor', force: true });
|
|
39
|
-
expect(fs.readFileSync(filePath, 'utf-8')).toContain('rigour_context_scope');
|
|
40
|
-
});
|
|
41
|
-
it('supports a no-write preview and portable playbooks', async () => {
|
|
42
|
-
await installSkills(testDir, ['handoff'], { target: 'universal', dryRun: true });
|
|
43
|
-
expect(fs.existsSync(path.join(testDir, 'docs', 'rigour-skills', 'rigour-handoff.md'))).toBe(false);
|
|
44
|
-
await installSkills(testDir, ['handoff'], { target: 'universal' });
|
|
45
|
-
const content = fs.readFileSync(path.join(testDir, 'docs', 'rigour-skills', 'rigour-handoff.md'), 'utf-8');
|
|
46
|
-
expect(content).toContain('portable Rigour playbook');
|
|
47
|
-
expect(content).toContain('rigour_handoff_accept');
|
|
48
|
-
});
|
|
49
|
-
it('reports missing and installed targets through doctor', async () => {
|
|
50
|
-
await skillsDoctor(testDir);
|
|
51
|
-
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('missing'));
|
|
52
|
-
await installSkills(testDir, ['all'], { target: 'all' });
|
|
53
|
-
await skillsDoctor(testDir);
|
|
54
|
-
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('All Rigour playbooks are installed'));
|
|
55
|
-
});
|
|
56
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { aboutAnotherRepository, buildActivity, groupSessions, readableFunction } from './studio-activity.js';
|
|
3
|
-
describe('buildActivity', () => {
|
|
4
|
-
it('turns the record into sentences, newest first, and leaves raw tool traffic out', () => {
|
|
5
|
-
const items = buildActivity({
|
|
6
|
-
events: [
|
|
7
|
-
{ type: 'hook_check', timestamp: '2026-10-09T10:00:00Z', files: ['src/pay.ts'], findings: [{ message: 'Stripe API key detected in code' }] },
|
|
8
|
-
{ type: 'tool_call', timestamp: '2026-10-09T10:01:00Z', tool: 'rigour_recall' },
|
|
9
|
-
{ type: 'stop_review', timestamp: '2026-10-09T10:03:00Z', blocked: true, blocking: 1 },
|
|
10
|
-
{ type: 'lessons_served', timestamp: '2026-10-09T10:04:00Z', via: 'recall', lessons: ['Amounts are cents'] },
|
|
11
|
-
],
|
|
12
|
-
ledger: [{ file: 'src/refund.ts', function: 'issueRefund', hash: 'h', reviewer: 'agent', verdict: 'fixed', note: 'Read the row under lock.', at: '2026-10-09T10:05:00Z' }],
|
|
13
|
-
stories: [{ id: 's', at: '2026-10-09T10:02:00Z', stage: 'edit', file: 'src/pay.ts', rule: 'security-patterns', title: 'Stripe API key detected in code', diff: [] }],
|
|
14
|
-
});
|
|
15
|
-
expect(items.map(i => i.text)).toEqual([
|
|
16
|
-
'Fixed `issueRefund` in src/refund.ts',
|
|
17
|
-
'Told the agent 1 lesson before it wrote',
|
|
18
|
-
'Kept the agent working: 1 problem left when it tried to finish',
|
|
19
|
-
'Fixed: Stripe API key detected in code',
|
|
20
|
-
'Stopped an edit to src/pay.ts: Stripe API key detected in code',
|
|
21
|
-
]);
|
|
22
|
-
});
|
|
23
|
-
it('names unnamed functions the way people do', () => {
|
|
24
|
-
expect(readableFunction('<anonymous>@17', 'src/proxy.test.ts')).toBe('the test at line 17');
|
|
25
|
-
expect(readableFunction('<anonymous>@4', 'src/a.ts')).toBe('the function at line 4');
|
|
26
|
-
expect(readableFunction('loadDiagnosis', 'src/a.ts')).toBe('`loadDiagnosis`');
|
|
27
|
-
});
|
|
28
|
-
it('groups activity into sessions, with fixes up front and routine items folded', () => {
|
|
29
|
-
const item = (at, kind, text = 'x') => ({ at, kind, text });
|
|
30
|
-
const sessions = groupSessions([
|
|
31
|
-
item('2026-10-09T12:00:00Z', 'reviewed', 'Reviewed `a` in a.ts: no issue'),
|
|
32
|
-
item('2026-10-09T11:50:00Z', 'reviewed', 'Fixed `b` in b.ts'),
|
|
33
|
-
item('2026-10-09T11:40:00Z', 'checked'),
|
|
34
|
-
item('2026-10-09T08:00:00Z', 'stopped'),
|
|
35
|
-
]);
|
|
36
|
-
expect(sessions).toHaveLength(2);
|
|
37
|
-
expect(sessions[0].counts).toMatchObject({ reviewed: 2, reviewedFixed: 1, checked: 1 });
|
|
38
|
-
expect(sessions[0].highlights.map(i => i.text)).toEqual(['Fixed `b` in b.ts']);
|
|
39
|
-
expect(sessions[0].rest).toHaveLength(2);
|
|
40
|
-
expect(sessions[1].highlights).toHaveLength(1);
|
|
41
|
-
});
|
|
42
|
-
it("leaves out old edit events that were about another repository's files", () => {
|
|
43
|
-
expect(aboutAnotherRepository({ type: 'hook_check', files: ['/work/other/src/a.ts'] }, ['/work/app'])).toBe(true);
|
|
44
|
-
expect(aboutAnotherRepository({ type: 'hook_check', files: ['/work/app/src/a.ts'] }, ['/work/app'])).toBe(false);
|
|
45
|
-
expect(aboutAnotherRepository({ type: 'hook_check', files: ['src/a.ts'] }, ['/work/app'])).toBe(false);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'child_process';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
-
import { checkoutRoots } from './studio-checkouts.js';
|
|
7
|
-
let repo;
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
repo = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'checkouts-')));
|
|
10
|
-
execFileSync('git', ['init', '-q', repo]);
|
|
11
|
-
execFileSync('git', ['-C', repo, '-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
|
|
12
|
-
fs.mkdirSync(path.join(repo, '.rigour'));
|
|
13
|
-
});
|
|
14
|
-
afterEach(() => fs.rmSync(repo, { recursive: true, force: true }));
|
|
15
|
-
describe('checkoutRoots', () => {
|
|
16
|
-
it('lists each checkout once, however the folder is spelled', () => {
|
|
17
|
-
const tree = path.join(repo, 'wt');
|
|
18
|
-
execFileSync('git', ['-C', repo, 'worktree', 'add', '-q', tree]);
|
|
19
|
-
fs.mkdirSync(path.join(tree, '.rigour'));
|
|
20
|
-
// git reports these folders with forward slashes; started from a trailing-slash path
|
|
21
|
-
expect(checkoutRoots(repo + path.sep)).toEqual([repo, fs.realpathSync.native(tree)]);
|
|
22
|
-
});
|
|
23
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildAgentContext } from './studio-context.js';
|
|
3
|
-
const now = new Date('2026-10-09T12:00:00Z');
|
|
4
|
-
const t = (iso) => Date.parse(iso);
|
|
5
|
-
describe('buildAgentContext', () => {
|
|
6
|
-
it('sums what agents were given this week: focused files, recalls, lessons, reuse', () => {
|
|
7
|
-
const ctx = buildAgentContext({
|
|
8
|
-
now,
|
|
9
|
-
context: [
|
|
10
|
-
{ toolName: 'rigour_context_scope', cacheStatus: 'none', candidateFiles: 180, returnedFiles: 6, candidateTokens: 90000, returnedTokens: 4000, createdAt: t('2026-10-08T00:00:00Z') },
|
|
11
|
-
{ toolName: 'rigour_context_scope', cacheStatus: 'none', candidateFiles: 180, returnedFiles: 4, candidateTokens: 90000, returnedTokens: 3000, createdAt: t('2026-09-01T00:00:00Z') },
|
|
12
|
-
{ toolName: 'rigour_recall', cacheStatus: 'none', candidateTokens: 0, returnedTokens: 200, createdAt: t('2026-10-07T00:00:00Z') },
|
|
13
|
-
],
|
|
14
|
-
events: [
|
|
15
|
-
{ type: 'lessons_served', timestamp: '2026-10-07T00:00:00Z', lessons: ['a', 'b'] },
|
|
16
|
-
{ type: 'reuse_suggested', timestamp: '2026-10-08T00:00:00Z', planned: 'formatMoney', existing: 'formatCents in src/lib/money.ts:12', action: 'BLOCK' },
|
|
17
|
-
],
|
|
18
|
-
});
|
|
19
|
-
expect(ctx.week).toEqual({
|
|
20
|
-
scopes: 1, filesConsidered: 180, filesReturned: 6, tokensConsidered: 90000, tokensReturned: 4000, recalls: 1, lessonsTold: 2,
|
|
21
|
-
reuse: [{ at: '2026-10-08T00:00:00Z', planned: 'formatMoney', existing: 'formatCents in src/lib/money.ts:12', action: 'BLOCK' }],
|
|
22
|
-
});
|
|
23
|
-
expect(ctx.weeks.at(-1)).toMatchObject({ scopes: 1, filesReturned: 6, filesConsidered: 180, recalls: 1, lessonsTold: 2, reuse: 1 });
|
|
24
|
-
expect(ctx.weeks.reduce((n, w) => n + w.scopes, 0)).toBe(2); // September 1 is inside the 8 weeks
|
|
25
|
-
});
|
|
26
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { resolveStudioVersion } from './studio-contracts.js';
|
|
3
|
-
describe('resolveStudioVersion', () => {
|
|
4
|
-
it('prefers the released CLI version used to serve Studio', () => {
|
|
5
|
-
expect(resolveStudioVersion('6.2.1', '5.4.0')).toBe('6.2.1');
|
|
6
|
-
expect(resolveStudioVersion(undefined, '6.2.0')).toBe('6.2.0');
|
|
7
|
-
});
|
|
8
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { createStudioGuard, refuseStudioRequest, STUDIO_KEY_HEADER, studioLaunchUrl } from './studio-guard.js';
|
|
3
|
-
const guard = createStudioGuard([3000, 3001]);
|
|
4
|
-
const req = (method, headers) => ({ method, headers });
|
|
5
|
-
describe('Studio request guard', () => {
|
|
6
|
-
it('serves reads only to its own loopback host, so a rebound DNS name cannot read files', () => {
|
|
7
|
-
expect(refuseStudioRequest(req('GET', { host: '127.0.0.1:3000' }), guard)).toBeNull();
|
|
8
|
-
expect(refuseStudioRequest(req('GET', { host: 'localhost:3001' }), guard)).toBeNull();
|
|
9
|
-
expect(refuseStudioRequest(req('GET', { host: 'attacker.example:3000' }), guard)).toBe('unknown host');
|
|
10
|
-
expect(refuseStudioRequest(req('GET', {}), guard)).toBe('unknown host');
|
|
11
|
-
});
|
|
12
|
-
it('accepts a write only with the launch key and no foreign origin', () => {
|
|
13
|
-
const host = { host: '127.0.0.1:3000' };
|
|
14
|
-
expect(refuseStudioRequest(req('POST', { ...host, [STUDIO_KEY_HEADER]: guard.launchKey }), guard)).toBeNull();
|
|
15
|
-
expect(refuseStudioRequest(req('POST', { ...host, origin: 'http://127.0.0.1:3000', [STUDIO_KEY_HEADER]: guard.launchKey }), guard)).toBeNull();
|
|
16
|
-
expect(refuseStudioRequest(req('POST', host), guard)).toContain('missing studio key');
|
|
17
|
-
expect(refuseStudioRequest(req('DELETE', { ...host, [STUDIO_KEY_HEADER]: 'x'.repeat(48) }), guard)).toContain('wrong studio key');
|
|
18
|
-
expect(refuseStudioRequest(req('POST', { ...host, origin: 'https://evil.example', [STUDIO_KEY_HEADER]: guard.launchKey }), guard)).toBe('cross-origin write');
|
|
19
|
-
});
|
|
20
|
-
it('puts the key in the fragment, which the browser never sends to a server', () => {
|
|
21
|
-
expect(studioLaunchUrl('http://127.0.0.1:3000', guard)).toBe(`http://127.0.0.1:3000/#key=${guard.launchKey}`);
|
|
22
|
-
expect(createStudioGuard([3000]).launchKey).not.toBe(guard.launchKey);
|
|
23
|
-
});
|
|
24
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { saveLearnedRule } from '@rigour-labs/core';
|
|
6
|
-
import { loadStudioLearnedRules } from './studio-learned-rules.js';
|
|
7
|
-
const base = {
|
|
8
|
-
version: 1, message: 'm', severity: 'medium',
|
|
9
|
-
source: { commit: 'abc12345', file: 'src/http.ts', function: 'post' },
|
|
10
|
-
validation: { firesBefore: 1, firesAfter: 0, maxHits: 3, repoHits: ['src/other.ts:4'] },
|
|
11
|
-
};
|
|
12
|
-
describe('loadStudioLearnedRules', () => {
|
|
13
|
-
let cwd;
|
|
14
|
-
afterEach(() => { if (cwd)
|
|
15
|
-
fs.rmSync(cwd, { recursive: true, force: true }); cwd = undefined; });
|
|
16
|
-
it('describes each rule by what it requires and where it applies', () => {
|
|
17
|
-
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-learned-'));
|
|
18
|
-
saveLearnedRule(cwd, { ...base, id: 'require-option-redirect-aaaa1111', pattern: { template: 'require-option', callee: { level: 'declaration', key: 'src/http.ts#Deps.fetch' }, argIndex: 1, property: 'redirect' } });
|
|
19
|
-
saveLearnedRule(cwd, { ...base, id: 'require-guard-cache-control-bbbb2222', pattern: { template: 'require-guard', callee: { level: 'text', key: 'Response.json' }, property: 'cache-control', guard: 'stepsAvailable', appliesWhenArgsMention: false, scope: { file: 'src/route.ts', fn: 'GET>respond' } } });
|
|
20
|
-
const rules = loadStudioLearnedRules(cwd);
|
|
21
|
-
expect(rules.map(r => [r.requirement, r.appliesTo])).toEqual([
|
|
22
|
-
['`cache-control` conditional on `stepsAvailable`', 'regression guard for GET>respond in src/route.ts'],
|
|
23
|
-
['`redirect` in argument 2', 'declaration src/http.ts#Deps.fetch'],
|
|
24
|
-
]);
|
|
25
|
-
expect(rules[1].validation.repoHits).toEqual(['src/other.ts:4']);
|
|
26
|
-
});
|
|
27
|
-
it('is empty for a repository without learned rules', () => {
|
|
28
|
-
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-learned-'));
|
|
29
|
-
expect(loadStudioLearnedRules(cwd)).toEqual([]);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildLearning, readableFixLesson } from './studio-learning.js';
|
|
3
|
-
const now = new Date('2026-10-09T12:00:00Z');
|
|
4
|
-
const lesson = {
|
|
5
|
-
id: 'l1', repositoryId: 'r', visibility: 'team', state: 'validated', kind: 'fix',
|
|
6
|
-
subject: 'Fixed before: Credential header follows redirects (semantic-bugs). The cookie reaches fetch.',
|
|
7
|
-
evidence: { files: ['src/a.ts', 'src/b.ts'] }, confidence: 1, source: 'fix', createdAt: Date.parse('2026-09-20T00:00:00Z'), updatedAt: 0,
|
|
8
|
-
};
|
|
9
|
-
const story = (at, stage = 'edit') => ({ id: at, at, stage, file: 'src/c.ts', rule: 'semantic-bugs', title: 'Credential header follows redirects', diff: [] });
|
|
10
|
-
describe('buildLearning', () => {
|
|
11
|
-
it("follows a lesson from where it was learned to the repeats it stopped", () => {
|
|
12
|
-
const events = [
|
|
13
|
-
{ type: 'lessons_served', timestamp: '2026-10-01T00:00:00Z', via: 'recall', lessons: [lesson.subject] },
|
|
14
|
-
{ type: 'lessons_served', timestamp: '2026-10-02T00:00:00Z', via: 'review', lessons: [lesson.subject, 'other'] },
|
|
15
|
-
];
|
|
16
|
-
const learning = buildLearning({ now, lessons: [lesson], reviewLessons: [], stories: [story('2026-09-19T00:00:00Z', 'stop'), story('2026-10-05T00:00:00Z')], events });
|
|
17
|
-
expect(learning.lessons[0]).toMatchObject({
|
|
18
|
-
text: 'Credential header follows redirects: The cookie reaches fetch.',
|
|
19
|
-
origin: 'development', learnedFrom: '2 fixes as an agent tried to finish', scope: 'team',
|
|
20
|
-
told: 2, stoppedInDevelopment: 1, reachedPr: null,
|
|
21
|
-
});
|
|
22
|
-
expect(learning.prRecorded).toBe(false);
|
|
23
|
-
expect(learning.weeks.map(w => [w.stoppedInDevelopment, w.reachedPr])).toEqual([[0, null], [0, null], [0, null], [1, null]]);
|
|
24
|
-
});
|
|
25
|
-
it('counts repeats that reached a PR once branch reviews are recorded here', () => {
|
|
26
|
-
const events = [{ type: 'pr_catches', timestamp: '2026-10-08T00:00:00Z', findings: [{ rule: 'semantic-bugs', title: 'Credential header follows redirects', file: 'src/d.ts' }] }];
|
|
27
|
-
const learning = buildLearning({ now, lessons: [lesson], reviewLessons: [], stories: [], events });
|
|
28
|
-
expect(learning.lessons[0].reachedPr).toBe(1);
|
|
29
|
-
expect(learning.weeks[3].reachedPr).toBe(1);
|
|
30
|
-
});
|
|
31
|
-
it('does not repeat the title when the detail says the same thing', () => {
|
|
32
|
-
const same = { ...lesson, subject: 'Fixed before: Stripe API key detected in code (security-patterns). Stripe API key detected in code.' };
|
|
33
|
-
expect(buildLearning({ now, lessons: [same], reviewLessons: [], stories: [], events: [] }).lessons[0].text).toBe('Stripe API key detected in code');
|
|
34
|
-
});
|
|
35
|
-
it('shows a lesson once, in words, without a truncated title or rule tag', () => {
|
|
36
|
-
expect(readableFixLesson("Fixed before: [credential-redirect] The `x-hook-signature` header is sent through `fetch(endpoint, { method: 'POST', headers: { 'x-h…` (semantic-bugs). [credential-redirect] The `x-hook-signature` header is sent through `fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } })` without `redirect: \"manual\"`."))
|
|
37
|
-
.toBe("The `x-hook-signature` header is sent through `fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } })` without `redirect: \"manual\"`");
|
|
38
|
-
const twice = [lesson, { ...lesson, id: 'l2', visibility: 'personal' }];
|
|
39
|
-
expect(buildLearning({ now, lessons: twice, reviewLessons: [], stories: [], events: [] }).lessons).toHaveLength(1);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { describe, expect, it } from 'vitest';
|
|
5
|
-
import { buildProgress, precisionsAcross } from './studio-progress.js';
|
|
6
|
-
const now = new Date('2026-10-09T12:00:00Z');
|
|
7
|
-
const story = (openedAt, at) => ({ id: at, at, openedAt, stage: 'edit', file: 'a.ts', rule: 'r', title: 't', diff: [] });
|
|
8
|
-
const learning = {
|
|
9
|
-
prRecorded: false,
|
|
10
|
-
weeks: [{ from: '2026-09-25T12:00:00Z', stoppedInDevelopment: 0, reachedPr: null }, { from: '2026-10-02T12:00:00Z', stoppedInDevelopment: 2, reachedPr: null }],
|
|
11
|
-
lessons: [
|
|
12
|
-
{ id: 'a', text: 'Never follow redirects with credentials', origin: 'development', learnedFrom: '', learnedAt: '2026-09-26T00:00:00Z', state: 'validated', scope: 'team', told: 3, stoppedInDevelopment: 2, reachedPr: null, canDecide: false },
|
|
13
|
-
{ id: 'b', text: 'Amounts are cents', origin: 'development', learnedFrom: '', learnedAt: '2026-10-05T00:00:00Z', state: 'candidate', scope: 'this repo', told: 0, stoppedInDevelopment: 0, reachedPr: null, canDecide: true },
|
|
14
|
-
],
|
|
15
|
-
};
|
|
16
|
-
describe('buildProgress', () => {
|
|
17
|
-
it('reports each week from records: stopped, overruled, median time to fix, lessons so far', () => {
|
|
18
|
-
const progress = buildProgress({
|
|
19
|
-
now,
|
|
20
|
-
stories: [story('2026-10-03T10:00:00Z', '2026-10-03T10:02:00Z'), story('2026-10-04T10:00:00Z', '2026-10-04T10:10:00Z'), story('2026-09-27T09:00:00Z', '2026-09-27T09:04:00Z')],
|
|
21
|
-
dismissals: [{ key: 'k', reason: 'x', at: '2026-10-05T00:00:00Z' }],
|
|
22
|
-
learning,
|
|
23
|
-
checks: [],
|
|
24
|
-
});
|
|
25
|
-
expect(progress.weeks).toEqual([
|
|
26
|
-
{ from: '2026-09-25T12:00:00Z', stopped: 1, overruled: 0, repeatsStopped: 0, repeatsReachedPr: null, minutesToFix: 4, lessons: 1 },
|
|
27
|
-
{ from: '2026-10-02T12:00:00Z', stopped: 2, overruled: 1, repeatsStopped: 2, repeatsReachedPr: null, minutesToFix: 6, lessons: 2 },
|
|
28
|
-
]);
|
|
29
|
-
expect(progress.topLessons.map(l => l.id)).toEqual(['a']);
|
|
30
|
-
});
|
|
31
|
-
it('ranks checks by how often their findings were acted on, summed over every checkout', () => {
|
|
32
|
-
const roots = [0, 1].map(() => fs.mkdtempSync(path.join(os.tmpdir(), 'progress-')));
|
|
33
|
-
roots.forEach((root, i) => {
|
|
34
|
-
fs.mkdirSync(path.join(root, '.rigour'));
|
|
35
|
-
fs.writeFileSync(path.join(root, '.rigour', 'check-outcomes.json'), JSON.stringify({ 'security-patterns: Key': { fixed: 2 + i, dismissed: 0 }, 'ast: Too complex': { fixed: 0, dismissed: 3 } }));
|
|
36
|
-
});
|
|
37
|
-
const checks = buildProgress({ now, stories: [], dismissals: [], learning, checks: precisionsAcross(roots) }).checks;
|
|
38
|
-
expect(checks.map(c => [c.check, c.fixed, c.dismissed, c.muted])).toEqual([['security-patterns: Key', 5, 0, false], ['ast: Too complex', 0, 6, true]]);
|
|
39
|
-
roots.forEach(root => fs.rmSync(root, { recursive: true, force: true }));
|
|
40
|
-
});
|
|
41
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|