@maci0/dsh-cordis-review 0.17.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/LICENSE +21 -0
- package/README.md +126 -0
- package/cordis.patch.yml +10 -0
- package/icon.svg +5 -0
- package/lib/check.js +726 -0
- package/lib/cli.js +80 -0
- package/lib/frontmatter.js +308 -0
- package/lib/index.js +29 -0
- package/lib/skills.js +226 -0
- package/lib/types/check.d.ts +39 -0
- package/lib/types/cli.d.ts +10 -0
- package/lib/types/frontmatter.d.ts +46 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/skills.d.ts +64 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -0
- package/skills/cordis-doc-review/SKILL.md +207 -0
- package/skills/cordis-review/SKILL.md +210 -0
package/lib/cli.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `cordis-review-check [options] [root]`: print closed-form CORDIS tags.
|
|
4
|
+
*
|
|
5
|
+
* One engine (ast-grep, auto-detected) for every tag; without the binary each
|
|
6
|
+
* covered file warns on stderr and yields an LLM-fallback hit.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-cordis-review/cli
|
|
9
|
+
*/
|
|
10
|
+
import { stat } from 'node:fs/promises';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import { parseArgs } from 'node:util';
|
|
13
|
+
import { check } from './check.js';
|
|
14
|
+
const USAGE = `usage: cordis-review-check [options] [root]
|
|
15
|
+
|
|
16
|
+
Print closed-form CORDIS findings for <root> (default: the current directory).
|
|
17
|
+
-h, --help print this message
|
|
18
|
+
Exit status: 0 clean, 1 findings, 2 usage, path, or ast-grep error, or an
|
|
19
|
+
incomplete scan (each unreadable directory is named on stderr; findings from
|
|
20
|
+
the readable part still print).
|
|
21
|
+
`;
|
|
22
|
+
/** Print one error line and exit with the usage-error status. */
|
|
23
|
+
function fail(message) {
|
|
24
|
+
process.stderr.write(`cordis-check: ${message}\n`);
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
let positionals;
|
|
28
|
+
let help = false;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = parseArgs({
|
|
31
|
+
args: process.argv.slice(2),
|
|
32
|
+
options: { help: { type: 'boolean', short: 'h' } },
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
});
|
|
35
|
+
help = parsed.values.help === true;
|
|
36
|
+
positionals = parsed.positionals;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
const option = /'(-[^']+)'/.exec(error instanceof Error ? error.message : '');
|
|
40
|
+
fail(`unknown flag ${JSON.stringify(option?.[1] ?? '')}; try --help`);
|
|
41
|
+
}
|
|
42
|
+
if (help) {
|
|
43
|
+
process.stdout.write(USAGE);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
if (positionals.length > 1)
|
|
47
|
+
fail(`unexpected extra argument ${JSON.stringify(positionals[1])}; try --help`);
|
|
48
|
+
const rootArg = positionals[0];
|
|
49
|
+
if (rootArg !== undefined && rootArg.startsWith('-'))
|
|
50
|
+
fail(`unknown flag ${JSON.stringify(rootArg)}; try --help`);
|
|
51
|
+
const target = resolve(rootArg ?? '.');
|
|
52
|
+
let isDirectory = false;
|
|
53
|
+
try {
|
|
54
|
+
isDirectory = (await stat(target)).isDirectory();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
fail(`no such path: ${target}`);
|
|
58
|
+
}
|
|
59
|
+
if (!isDirectory)
|
|
60
|
+
fail(`not a directory: ${target}`);
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await check(target);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
67
|
+
}
|
|
68
|
+
for (const finding of result.findings) {
|
|
69
|
+
process.stdout.write(`${finding.file}:${finding.line}: ${finding.tag}: ${finding.message}\n`);
|
|
70
|
+
}
|
|
71
|
+
if (result.unreadable.length > 0) {
|
|
72
|
+
// An incomplete scan is never "clean": exit 2 even when nothing was found.
|
|
73
|
+
for (const { dir, reason } of result.unreadable)
|
|
74
|
+
process.stderr.write(`cordis-check: cannot read ${dir}: ${reason}\n`);
|
|
75
|
+
const count = result.unreadable.length;
|
|
76
|
+
fail(`incomplete scan: ${count} ${count === 1 ? 'directory' : 'directories'} unreadable`);
|
|
77
|
+
}
|
|
78
|
+
if (result.findings.length === 0)
|
|
79
|
+
process.stdout.write('cordis-check: clean\n');
|
|
80
|
+
process.exit(result.findings.length === 0 ? 0 : 1);
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontmatter reader for the bundled `SKILL.md` files.
|
|
3
|
+
*
|
|
4
|
+
* Almost every skill header is flat: `key: value`, a quoted scalar, or a
|
|
5
|
+
* `|`/`>` block of same-indent lines. A local reader handles that subset and
|
|
6
|
+
* changes nothing; it returns `undefined` the moment the block contains
|
|
7
|
+
* anything it cannot prove it understood (a nested map, a list, a quoted
|
|
8
|
+
* scalar with an escape, a more-indented or empty line inside a block scalar,
|
|
9
|
+
* a flow collection, a YAML-tag indicator). The caller then loads `yaml`
|
|
10
|
+
* dynamically and re-parses that one file, so the fallback stays the contract
|
|
11
|
+
* and a static `import('yaml')` no longer costs every boot ~11ms of CPU for a
|
|
12
|
+
* mapping it never needed.
|
|
13
|
+
*
|
|
14
|
+
* The fast path may only claim a block whose value is *provably* what `yaml`
|
|
15
|
+
* produces. Anything it cannot prove (a `\r` anywhere in the source, a quoted
|
|
16
|
+
* key, a duplicate key, a `#` in a value, a scalar that has a typed YAML form
|
|
17
|
+
* (`0x10`, `0o17`, `.inf`, `.nan`, `1_000`, `+5`, `-0`, leading zeros), a
|
|
18
|
+
* `+` keep-chomped block scalar) is refused and re-parsed by `yaml`. A false
|
|
19
|
+
* refusal costs one dynamic import; a false claim silently diverges.
|
|
20
|
+
*
|
|
21
|
+
* The delimiter scan stays local: frontmatter with no closing `---` is not
|
|
22
|
+
* frontmatter, and the body is then the whole source.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-cordis-review/frontmatter
|
|
25
|
+
*/
|
|
26
|
+
/** `key: value` at column zero, with nothing but horizontal space around the colon. */
|
|
27
|
+
const ENTRY = /^([^\s:#][^\s:#]*?)[ \t]*:([ \t]+[^\r\n]*|)$/;
|
|
28
|
+
/** A plain scalar with no leading indicator, no `#`, no `: ` mapping, no reserved start. */
|
|
29
|
+
const PLAIN = /^[^\s!&*\-?{}[\],#|>@`"'%:][^#:]*$/;
|
|
30
|
+
/** A double-quoted scalar with no backslash escape in it. */
|
|
31
|
+
const SIMPLE_DOUBLE = /^[^\\]*$/;
|
|
32
|
+
/** A single-quoted scalar with no `''` escape in it. */
|
|
33
|
+
const SIMPLE_SINGLE = /^[^']*$/;
|
|
34
|
+
/** The block scalar header this reader reads: style plus an optional strip flag. */
|
|
35
|
+
const BLOCK_HEADER = /^([|>])(-)?$/;
|
|
36
|
+
/** A plain scalar YAML types as an integer: `0x10`, `0o17`, `+5`, `-0`, `007`, `1_000`. */
|
|
37
|
+
const TYPED_INT = /^[-+]?(?:0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*)$/;
|
|
38
|
+
/** A plain scalar YAML types as a special float: `.inf`, `.nan`, either sign, any case. */
|
|
39
|
+
const TYPED_SPECIAL = /^[-+]?\.(?:inf|nan)$/i;
|
|
40
|
+
/** A plain scalar YAML types as a float: a leading `+`, leading zeros, a `.5`/`1.` body. */
|
|
41
|
+
const TYPED_FLOAT = /^(?:[-+]?[0-9][0-9_]*\.[0-9_]*|[-+]?\.[0-9][0-9_]*)$/i;
|
|
42
|
+
/** The decimal scalars this reader converts itself, with no YAML-only spelling. */
|
|
43
|
+
const SAFE_INT = /^-?(?:0|[1-9]\d*)$/;
|
|
44
|
+
const SAFE_FLOAT = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
|
|
45
|
+
/** A key this reader can prove `yaml` resolves to the same string. */
|
|
46
|
+
const SAFE_KEY = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
47
|
+
/** Code points JS `trim` strips but YAML counts as content: indentation is unprovable. */
|
|
48
|
+
const JS_ONLY_SPACE = /[\u000B\u000C\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]/;
|
|
49
|
+
/** Keys YAML resolves to a non-string: `null` becomes `''` and `True` becomes `'true'`. */
|
|
50
|
+
const RESOLVED_KEY = /^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/;
|
|
51
|
+
/** Keys that would not survive `data[key] = value` on an object literal. */
|
|
52
|
+
const UNSAFE_KEY = new Set(['__proto__']);
|
|
53
|
+
/**
|
|
54
|
+
* Count the spaces a line starts with. YAML indentation is spaces; a tab or a
|
|
55
|
+
* code point JS treats as blank is not this reader's to interpret.
|
|
56
|
+
* @param line - one line of the block.
|
|
57
|
+
* @returns the number of leading spaces.
|
|
58
|
+
*/
|
|
59
|
+
function leadingSpaces(line) {
|
|
60
|
+
let count = 0;
|
|
61
|
+
while (count < line.length && line.charCodeAt(count) === 32)
|
|
62
|
+
count += 1;
|
|
63
|
+
return count;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Split a data block into lines.
|
|
67
|
+
* @param text - the data block, already newline-normalized.
|
|
68
|
+
* @returns the lines, `\n`-separated.
|
|
69
|
+
*/
|
|
70
|
+
function toLines(text) {
|
|
71
|
+
return text.split('\n');
|
|
72
|
+
}
|
|
73
|
+
/** The closing (and opening) delimiter line, exactly as the pre-change reader tested it. */
|
|
74
|
+
const DELIMITER = /^---[ \t]*$/;
|
|
75
|
+
/**
|
|
76
|
+
* Split one document into its leading frontmatter block and the body, the way
|
|
77
|
+
* the pre-change reader did: line by line, so the string handed to `yaml` is
|
|
78
|
+
* byte-identical to what it parsed before the fast path existed.
|
|
79
|
+
* @param source - the file's contents.
|
|
80
|
+
* @returns the block, the body, and whether a delimited block was present.
|
|
81
|
+
*/
|
|
82
|
+
function splitDocument(source) {
|
|
83
|
+
const text = source.replace(/^\uFEFF/, '');
|
|
84
|
+
const lines = text.split(/\r?\n/);
|
|
85
|
+
if (lines[0] === undefined || !DELIMITER.test(lines[0]))
|
|
86
|
+
return { block: '', body: text, present: false };
|
|
87
|
+
let closing = -1;
|
|
88
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
89
|
+
if (DELIMITER.test(lines[index] ?? '')) {
|
|
90
|
+
closing = index;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (closing === -1)
|
|
95
|
+
return { block: '', body: text, present: false };
|
|
96
|
+
return { block: lines.slice(1, closing).join('\n'), body: lines.slice(closing + 1).join('\n'), present: true };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Read one block scalar: same-indent lines only, chomping as YAML defines it.
|
|
100
|
+
* @param lines - the block's lines.
|
|
101
|
+
* @param header - index of the `key: >` line.
|
|
102
|
+
* @param headerValue - the `>` or `|` indicator, optionally followed by `-`.
|
|
103
|
+
* @returns the scalar and the index one past the block, or `undefined` when the
|
|
104
|
+
* block has a deeper-indented line, an interior blank line, or no content.
|
|
105
|
+
*/
|
|
106
|
+
function readBlockScalar(lines, header, headerValue) {
|
|
107
|
+
const style = headerValue[0];
|
|
108
|
+
const modifier = headerValue.slice(1);
|
|
109
|
+
const first = lines[header + 1];
|
|
110
|
+
if (first === undefined)
|
|
111
|
+
return undefined;
|
|
112
|
+
// Indentation is spaces only: a tab is a YAML parse error there, so a
|
|
113
|
+
// tab-led or unindented first line leaves the block to `yaml`.
|
|
114
|
+
const indent = leadingSpaces(first);
|
|
115
|
+
if (indent === 0)
|
|
116
|
+
return undefined;
|
|
117
|
+
const content = [];
|
|
118
|
+
let index = header + 1;
|
|
119
|
+
let closed = false;
|
|
120
|
+
for (; index < lines.length; index += 1) {
|
|
121
|
+
const line = lines[index];
|
|
122
|
+
if (leadingSpaces(line) < indent) {
|
|
123
|
+
closed = true;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
if (line.length === indent) {
|
|
127
|
+
// An interior blank line folds differently; only a trailing run may stay.
|
|
128
|
+
if (index + 1 < lines.length && lines[index + 1].length >= indent)
|
|
129
|
+
return undefined;
|
|
130
|
+
content.push('');
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (leadingSpaces(line) > indent)
|
|
134
|
+
return undefined;
|
|
135
|
+
if (line.charCodeAt(indent) === 9)
|
|
136
|
+
return undefined;
|
|
137
|
+
content.push(line.slice(indent, line.length));
|
|
138
|
+
}
|
|
139
|
+
// A block that runs to the end of the document is closed there.
|
|
140
|
+
if (!closed && index >= lines.length)
|
|
141
|
+
closed = true;
|
|
142
|
+
if (!closed)
|
|
143
|
+
return undefined;
|
|
144
|
+
if (content.length === 0)
|
|
145
|
+
return undefined;
|
|
146
|
+
let last = content.length;
|
|
147
|
+
while (last > 0 && content[last - 1] === '')
|
|
148
|
+
last -= 1;
|
|
149
|
+
const kept = content.slice(0, last);
|
|
150
|
+
if (kept.length === 0)
|
|
151
|
+
return undefined;
|
|
152
|
+
const body = style === '|' ? kept.join('\n') : kept.join(' ');
|
|
153
|
+
if (modifier.includes('-'))
|
|
154
|
+
return { value: body, next: index };
|
|
155
|
+
return { value: `${body}\n`, next: index };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Read one scalar value.
|
|
159
|
+
* @param raw - the value text, right-trimmed.
|
|
160
|
+
* @returns the value, or `undefined` when it needs the real YAML parser.
|
|
161
|
+
*/
|
|
162
|
+
function readScalar(raw) {
|
|
163
|
+
// A `#` needs YAML's comment rules (one only after whitespace) to read: refuse.
|
|
164
|
+
if (raw.includes('#'))
|
|
165
|
+
return undefined;
|
|
166
|
+
// `.inf` / `.nan` are YAML's special floats, in any case and either sign.
|
|
167
|
+
if (TYPED_SPECIAL.test(raw))
|
|
168
|
+
return undefined;
|
|
169
|
+
if (raw === '' || raw === '~' || raw === 'null' || raw === 'Null' || raw === 'NULL')
|
|
170
|
+
return { value: null };
|
|
171
|
+
if (raw === 'true' || raw === 'True' || raw === 'TRUE')
|
|
172
|
+
return { value: true };
|
|
173
|
+
if (raw === 'false' || raw === 'False' || raw === 'FALSE')
|
|
174
|
+
return { value: false };
|
|
175
|
+
if (/[0-9]/.test(raw)) {
|
|
176
|
+
// A digit anywhere means YAML may type this scalar; only the spellings this
|
|
177
|
+
// reader converts identically may pass, every other form goes to `yaml`.
|
|
178
|
+
if (SAFE_INT.test(raw) || SAFE_FLOAT.test(raw))
|
|
179
|
+
return { value: Number(raw) };
|
|
180
|
+
// Any other exponent spelling is YAML's floatExp, whose mantissa may be
|
|
181
|
+
// `.5`, `1.`, or zero-padded (`01e9`, `00e0`), none of which this reader
|
|
182
|
+
// converts, so it must not claim the block.
|
|
183
|
+
if (/[eE]/.test(raw))
|
|
184
|
+
return undefined;
|
|
185
|
+
if (TYPED_INT.test(raw) || TYPED_FLOAT.test(raw) || /^[-+]/.test(raw) || raw.includes('_'))
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const first = raw.charCodeAt(0);
|
|
189
|
+
if (first === 34) {
|
|
190
|
+
if (!SIMPLE_DOUBLE.test(raw.slice(1)))
|
|
191
|
+
return undefined;
|
|
192
|
+
const closing = raw.indexOf('"', 1);
|
|
193
|
+
if (closing === -1 || raw.slice(closing + 1).trim() !== '')
|
|
194
|
+
return undefined;
|
|
195
|
+
return { value: raw.slice(1, closing) };
|
|
196
|
+
}
|
|
197
|
+
if (first === 39) {
|
|
198
|
+
if (!SIMPLE_SINGLE.test(raw.slice(1)))
|
|
199
|
+
return undefined;
|
|
200
|
+
const closing = raw.indexOf("'", 1);
|
|
201
|
+
if (closing === -1 || raw.slice(closing + 1).trim() !== '')
|
|
202
|
+
return undefined;
|
|
203
|
+
return { value: raw.slice(1, closing) };
|
|
204
|
+
}
|
|
205
|
+
if (!PLAIN.test(raw))
|
|
206
|
+
return undefined;
|
|
207
|
+
return { value: raw };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Read a flat block of `key: value` entries.
|
|
211
|
+
* @param block - the block's own text, `\n`-separated.
|
|
212
|
+
* @returns the mapping, or `undefined` when any line needs the real YAML parser.
|
|
213
|
+
*/
|
|
214
|
+
function parseFlatBlock(block) {
|
|
215
|
+
// `trim`/`trimStart` in this reader would measure indentation through these
|
|
216
|
+
// and YAML would not: the real parser has to decide.
|
|
217
|
+
if (JS_ONLY_SPACE.test(block))
|
|
218
|
+
return undefined;
|
|
219
|
+
const lines = toLines(block);
|
|
220
|
+
const data = {};
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
223
|
+
const line = lines[index];
|
|
224
|
+
if (line === '' || line.charCodeAt(0) === 35)
|
|
225
|
+
continue;
|
|
226
|
+
const entry = ENTRY.exec(line);
|
|
227
|
+
if (entry === null)
|
|
228
|
+
return undefined;
|
|
229
|
+
const key = entry[1].trimEnd();
|
|
230
|
+
if (key !== entry[1])
|
|
231
|
+
return undefined;
|
|
232
|
+
// Only a key that is provably its own string: that excludes quoted keys,
|
|
233
|
+
// flow keys, keys starting with an indicator (`@a`, `|a`, `[a]`), typed
|
|
234
|
+
// keys (`0x10`), and the words YAML resolves to `null`/`true`/`false`.
|
|
235
|
+
if (!SAFE_KEY.test(key))
|
|
236
|
+
return undefined;
|
|
237
|
+
if (RESOLVED_KEY.test(key))
|
|
238
|
+
return undefined;
|
|
239
|
+
// `yaml` rejects a duplicate key and a `__proto__` key does not survive a
|
|
240
|
+
// plain object assignment; both need the real parser.
|
|
241
|
+
if (seen.has(key) || UNSAFE_KEY.has(key))
|
|
242
|
+
return undefined;
|
|
243
|
+
seen.add(key);
|
|
244
|
+
const raw = entry[2].replace(/^[ \t]+/, '').replace(/[ \t]+$/, '');
|
|
245
|
+
if (raw === '') {
|
|
246
|
+
// A value on following lines is a nested map or a sequence.
|
|
247
|
+
const next = lines[index + 1];
|
|
248
|
+
if (next !== undefined && next.trimStart() !== '' && next.charCodeAt(0) !== 35)
|
|
249
|
+
return undefined;
|
|
250
|
+
data[key] = null;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (raw.charCodeAt(0) === 124 || raw.charCodeAt(0) === 62) {
|
|
254
|
+
// Only clip and strip chomping are read here; `+` keeps every trailing
|
|
255
|
+
// line break, which this line-based reader does not count.
|
|
256
|
+
if (BLOCK_HEADER.exec(raw) === null)
|
|
257
|
+
return undefined;
|
|
258
|
+
const scalar = readBlockScalar(lines, index, raw);
|
|
259
|
+
if (scalar === undefined)
|
|
260
|
+
return undefined;
|
|
261
|
+
data[key] = scalar.value;
|
|
262
|
+
index = scalar.next - 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const scalar = readScalar(raw);
|
|
266
|
+
if (scalar === undefined)
|
|
267
|
+
return undefined;
|
|
268
|
+
data[key] = scalar.value;
|
|
269
|
+
}
|
|
270
|
+
return data;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Parse leading frontmatter from a markdown document, for the flat subset only.
|
|
274
|
+
* @param source - full file contents.
|
|
275
|
+
* @returns the mapping and body, or `undefined` when this file's block needs
|
|
276
|
+
* the real YAML parser ({@link parseFrontmatterWithYaml}).
|
|
277
|
+
*/
|
|
278
|
+
export function parseFrontmatter(source) {
|
|
279
|
+
// `\r` is a line break to YAML and not to this reader: hand the whole file,
|
|
280
|
+
// CRLF included, to the real parser instead of claiming the block.
|
|
281
|
+
if (source.includes('\r'))
|
|
282
|
+
return undefined;
|
|
283
|
+
const { block, body, present } = splitDocument(source);
|
|
284
|
+
if (!present)
|
|
285
|
+
return { data: {}, body };
|
|
286
|
+
const data = parseFlatBlock(block);
|
|
287
|
+
if (data === undefined)
|
|
288
|
+
return undefined;
|
|
289
|
+
return { data, body };
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Parse leading frontmatter with the `yaml` parser the harness's own filesystem
|
|
293
|
+
* skill provider uses. Loaded dynamically, so a flat header never pays for it.
|
|
294
|
+
* @param source - full file contents.
|
|
295
|
+
* @returns the parsed mapping and the remaining body.
|
|
296
|
+
*/
|
|
297
|
+
export async function parseFrontmatterWithYaml(source) {
|
|
298
|
+
const { block, body, present } = splitDocument(source);
|
|
299
|
+
if (!present)
|
|
300
|
+
return { data: {}, body };
|
|
301
|
+
const { parse } = await import('yaml');
|
|
302
|
+
const parsed = parse(block);
|
|
303
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
304
|
+
// Empty or non-mapping frontmatter: no keys, but the body still loads.
|
|
305
|
+
return { data: {}, body };
|
|
306
|
+
}
|
|
307
|
+
return { data: parsed, body };
|
|
308
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cordis-review: CORDIS review skills as a DeepSeek Harness plugin.
|
|
3
|
+
*
|
|
4
|
+
* One capability: every bundled `skills/<name>/SKILL.md` becomes a
|
|
5
|
+
* `ctx.skills` provider entry, so each appears as `/<name>` in the composer
|
|
6
|
+
* (DSH's command surface for user-invocable skills). `cordis-review` reviews
|
|
7
|
+
* a codebase against the CORDIS context paradigm (arXiv:2608.25512) and
|
|
8
|
+
* applies the fixes; `cordis-doc-review` writes and repairs the docs.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-cordis-review
|
|
11
|
+
*/
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { createSkillProvider } from './skills.js';
|
|
15
|
+
/** Plugin name as it appears in the loader. */
|
|
16
|
+
export const name = 'cordis-review';
|
|
17
|
+
/**
|
|
18
|
+
* Mount the plugin.
|
|
19
|
+
* @param ctx - the host context.
|
|
20
|
+
*/
|
|
21
|
+
export function apply(ctx) {
|
|
22
|
+
const skillsDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
23
|
+
ctx.inject(['skills'], (scope) => {
|
|
24
|
+
const warn = (message) => {
|
|
25
|
+
scope.logger.warn(`[cordis-review] ${message}`);
|
|
26
|
+
};
|
|
27
|
+
scope.skills.registerProvider(() => createSkillProvider({ skillsDir, onWarn: warn }));
|
|
28
|
+
});
|
|
29
|
+
}
|
package/lib/skills.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundled cordis-review skill as a `ctx.skills` provider.
|
|
3
|
+
*
|
|
4
|
+
* Skills are read from this package's `skills/<name>/SKILL.md`, so that file
|
|
5
|
+
* stays the single source of truth for `/cordis-review`.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-cordis-review/skills
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
10
|
+
import { basename, dirname, join } from 'node:path';
|
|
11
|
+
import { BUNDLED_SKILL_RANK, isSkillName } from '@deepseek-ai/dsh-skill';
|
|
12
|
+
import { parseFrontmatter, parseFrontmatterWithYaml } from './frontmatter.js';
|
|
13
|
+
/**
|
|
14
|
+
* Rank matching a harness bundled skill, re-exported from the registry so a
|
|
15
|
+
* project-level or user-level skill of the same name still wins the duplicate.
|
|
16
|
+
*/
|
|
17
|
+
export { BUNDLED_SKILL_RANK };
|
|
18
|
+
/** Provider name inside the skill registry. */
|
|
19
|
+
const PROVIDER_NAME = 'cordis-review';
|
|
20
|
+
/** Instruction file every skill directory must carry. */
|
|
21
|
+
const INSTRUCTION_FILE = 'SKILL.md';
|
|
22
|
+
/** Frontmatter keys the harness defines; they project into named summary fields, never into `metadata`. */
|
|
23
|
+
const SUMMARY_KEYS = new Set(['name', 'description', 'whenToUse', 'disable-model-invocation', 'user-invocable']);
|
|
24
|
+
/** Pre-hyphenated invocation keys the harness refuses, with the key that replaced each. */
|
|
25
|
+
const LEGACY_INVOCATION_KEYS = {
|
|
26
|
+
disableModelInvocation: 'disable-model-invocation',
|
|
27
|
+
modelInvocable: 'disable-model-invocation',
|
|
28
|
+
userInvocable: 'user-invocable',
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Read one invocation flag with the spellings the harness filesystem provider
|
|
32
|
+
* accepts: a YAML boolean, `1`/`0`, or `true`/`yes`/`on`/`false`/`no`/`off`
|
|
33
|
+
* in any case.
|
|
34
|
+
* @param data - the frontmatter mapping.
|
|
35
|
+
* @param key - the invocation key.
|
|
36
|
+
* @returns the flag, or `undefined` when the key is absent.
|
|
37
|
+
* @throws TypeError when the value is none of those spellings.
|
|
38
|
+
*/
|
|
39
|
+
function frontmatterBoolean(data, key) {
|
|
40
|
+
if (!Object.hasOwn(data, key))
|
|
41
|
+
return undefined;
|
|
42
|
+
const value = data[key];
|
|
43
|
+
if (typeof value === 'boolean')
|
|
44
|
+
return value;
|
|
45
|
+
if (value === 1 || value === '1')
|
|
46
|
+
return true;
|
|
47
|
+
if (value === 0 || value === '0')
|
|
48
|
+
return false;
|
|
49
|
+
if (typeof value === 'string') {
|
|
50
|
+
const word = value.toLowerCase();
|
|
51
|
+
if (word === 'true' || word === 'yes' || word === 'on')
|
|
52
|
+
return true;
|
|
53
|
+
if (word === 'false' || word === 'no' || word === 'off')
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
throw new TypeError(`frontmatter field "${key}" must be a boolean`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the invocation policy the way the harness filesystem provider does.
|
|
60
|
+
* @param data - the frontmatter mapping.
|
|
61
|
+
* @returns the policy; both surfaces default to on.
|
|
62
|
+
* @throws Error for a legacy camelCase key or a non-boolean value.
|
|
63
|
+
*/
|
|
64
|
+
function parseInvocationPolicy(data) {
|
|
65
|
+
for (const [legacy, canonical] of Object.entries(LEGACY_INVOCATION_KEYS)) {
|
|
66
|
+
if (Object.hasOwn(data, legacy))
|
|
67
|
+
throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
modelInvocable: frontmatterBoolean(data, 'disable-model-invocation') !== true,
|
|
71
|
+
userInvocable: frontmatterBoolean(data, 'user-invocable') !== false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read and parse one skill file. Shared by discovery and direct loads so a
|
|
76
|
+
* single file enforces the name/description/frontmatter rules everywhere.
|
|
77
|
+
* @param path - absolute path of the `SKILL.md` file.
|
|
78
|
+
* @param onWarn - optional non-fatal problem sink.
|
|
79
|
+
* @param entryName - directory name fallback when frontmatter omits `name`.
|
|
80
|
+
* @param signal - aborts the read for a caller that no longer wants the result.
|
|
81
|
+
* @returns the parsed skill, or `undefined` with a warning when invalid.
|
|
82
|
+
*/
|
|
83
|
+
async function readSkillFile(path, onWarn, entryName, signal) {
|
|
84
|
+
if (signal?.aborted)
|
|
85
|
+
return undefined;
|
|
86
|
+
let source;
|
|
87
|
+
try {
|
|
88
|
+
source = await readFile(path, { encoding: 'utf8', signal });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (!signal?.aborted)
|
|
92
|
+
onWarn?.(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
// The flat reader covers every header this package ships. Its refusal is
|
|
98
|
+
// what selects the real YAML parser, so the fallback stays the contract.
|
|
99
|
+
parsed = parseFrontmatter(source) ?? await parseFrontmatterWithYaml(source);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
onWarn?.(`skipping ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const fallback = entryName ?? basename(path);
|
|
106
|
+
const name = String(parsed.data['name'] ?? fallback).trim();
|
|
107
|
+
const description = String(parsed.data['description'] ?? '').trim();
|
|
108
|
+
if (!isSkillName(name)) {
|
|
109
|
+
onWarn?.(`skipping ${path}: "${name}" is not a valid kebab-case skill name`);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
if (description === '') {
|
|
113
|
+
onWarn?.(`skipping ${path}: frontmatter has no description`);
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
let invocation;
|
|
117
|
+
try {
|
|
118
|
+
invocation = parseInvocationPolicy(parsed.data);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
onWarn?.(`skipping ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
const metadata = {};
|
|
125
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
126
|
+
if (SUMMARY_KEYS.has(key))
|
|
127
|
+
continue;
|
|
128
|
+
metadata[key] = value;
|
|
129
|
+
}
|
|
130
|
+
const whenToUse = String(parsed.data['whenToUse'] ?? '').trim();
|
|
131
|
+
return {
|
|
132
|
+
name,
|
|
133
|
+
description,
|
|
134
|
+
...whenToUse === '' ? {} : { whenToUse },
|
|
135
|
+
invocation,
|
|
136
|
+
content: parsed.body.trim(),
|
|
137
|
+
metadata,
|
|
138
|
+
path,
|
|
139
|
+
directory: dirname(path),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Read every valid skill directory under `skillsDir`.
|
|
144
|
+
*
|
|
145
|
+
* A missing directory, a directory without `SKILL.md`, a file whose frontmatter
|
|
146
|
+
* the reader refuses, and a file with a missing description or an invalid
|
|
147
|
+
* invocation key are reported through `onWarn` and skipped: one broken file
|
|
148
|
+
* must not cost the catalog its other skills.
|
|
149
|
+
* @param skillsDir - directory holding one subdirectory per skill.
|
|
150
|
+
* @param onWarn - optional non-fatal problem sink.
|
|
151
|
+
* @param signal - aborts discovery for a caller that no longer wants the result.
|
|
152
|
+
* @returns the parsed skills, sorted by name.
|
|
153
|
+
*/
|
|
154
|
+
export async function discoverSkills(skillsDir, onWarn, signal) {
|
|
155
|
+
if (signal?.aborted)
|
|
156
|
+
return [];
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (signal?.aborted)
|
|
163
|
+
return [];
|
|
164
|
+
onWarn?.(`cannot read skills directory ${skillsDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
const skills = [];
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
if (signal?.aborted)
|
|
170
|
+
break;
|
|
171
|
+
if (!entry.isDirectory())
|
|
172
|
+
continue;
|
|
173
|
+
const path = join(skillsDir, entry.name, INSTRUCTION_FILE);
|
|
174
|
+
const skill = await readSkillFile(path, onWarn, entry.name, signal);
|
|
175
|
+
if (skill !== undefined)
|
|
176
|
+
skills.push(skill);
|
|
177
|
+
}
|
|
178
|
+
return skills.sort((left, right) => left.name.localeCompare(right.name));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Build the provider the skill registry mounts.
|
|
182
|
+
* @param options - skills directory and the non-fatal problem sink.
|
|
183
|
+
* @returns a provider whose candidates are summaries and whose bodies come from disk.
|
|
184
|
+
*/
|
|
185
|
+
export function createSkillProvider(options) {
|
|
186
|
+
const summaryOf = (skill) => ({
|
|
187
|
+
path: skill.path,
|
|
188
|
+
name: skill.name,
|
|
189
|
+
description: skill.description,
|
|
190
|
+
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
|
|
191
|
+
invocation: skill.invocation,
|
|
192
|
+
source: 'bundled',
|
|
193
|
+
provider: PROVIDER_NAME,
|
|
194
|
+
resourceBase: { kind: 'directory', path: skill.directory },
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
name: PROVIDER_NAME,
|
|
198
|
+
async list(lookup) {
|
|
199
|
+
let complete = true;
|
|
200
|
+
const skills = await discoverSkills(options.skillsDir, (message) => {
|
|
201
|
+
complete = false;
|
|
202
|
+
options.onWarn?.(message);
|
|
203
|
+
}, lookup?.signal);
|
|
204
|
+
const candidates = skills.map((skill) => ({
|
|
205
|
+
...summaryOf(skill),
|
|
206
|
+
rank: BUNDLED_SKILL_RANK,
|
|
207
|
+
locator: skill.path,
|
|
208
|
+
metadata: skill.metadata,
|
|
209
|
+
}));
|
|
210
|
+
return complete ? candidates : { candidates, complete: false };
|
|
211
|
+
},
|
|
212
|
+
async get(candidate, lookup) {
|
|
213
|
+
if (typeof candidate.locator !== 'string')
|
|
214
|
+
return undefined;
|
|
215
|
+
// Read the locator directly: one file instead of a full re-discovery.
|
|
216
|
+
// The name check keeps a stale candidate (path reused by another skill)
|
|
217
|
+
// from loading under the wrong identity.
|
|
218
|
+
// Same directory-name fallback `list` applies, so a skill that omits
|
|
219
|
+
// `name` in frontmatter is not listed under an identity `get` refuses.
|
|
220
|
+
const skill = await readSkillFile(candidate.locator, options.onWarn, basename(dirname(candidate.locator)), lookup?.signal);
|
|
221
|
+
if (skill === undefined || skill.name !== candidate.name)
|
|
222
|
+
return undefined;
|
|
223
|
+
return { ...summaryOf(skill), content: skill.content, metadata: skill.metadata };
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic CORDIS tags, converged on one engine: ast-grep.
|
|
3
|
+
*
|
|
4
|
+
* Every tag (`mix-export`, `inject`, `toplevel`, `id`) is an ast-grep query.
|
|
5
|
+
* The engine is auto-detected: when the binary is missing, every covered file
|
|
6
|
+
* warns on stderr and yields an LLM-fallback handoff in the message (the
|
|
7
|
+
* review agent reads the message and judges the file).
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-cordis-review/check
|
|
10
|
+
*/
|
|
11
|
+
/** One closed-form hit. */
|
|
12
|
+
export interface Finding {
|
|
13
|
+
/** Checklist tag. */
|
|
14
|
+
readonly tag: 'mix-export' | 'inject' | 'toplevel' | 'id';
|
|
15
|
+
/** Path relative to the scan root. */
|
|
16
|
+
readonly file: string;
|
|
17
|
+
/** 1-based line. */
|
|
18
|
+
readonly line: number;
|
|
19
|
+
/** What to change. */
|
|
20
|
+
readonly message: string;
|
|
21
|
+
}
|
|
22
|
+
/** A directory the walk could not list; nothing under it was scanned. */
|
|
23
|
+
export interface Unreadable {
|
|
24
|
+
/** Path relative to the scan root (`.` for the root itself). */
|
|
25
|
+
readonly dir: string;
|
|
26
|
+
/** The listing error's message. */
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
}
|
|
29
|
+
/** One scan: its findings, and the directories it could not read. */
|
|
30
|
+
export interface CheckResult {
|
|
31
|
+
readonly findings: readonly Finding[];
|
|
32
|
+
/** Non-empty means the scan is incomplete. */
|
|
33
|
+
readonly unreadable: readonly Unreadable[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Scan `root` for the closed-form tags.
|
|
37
|
+
* @param root - workspace (or subdirectory) to walk.
|
|
38
|
+
*/
|
|
39
|
+
export declare function check(root: string): Promise<CheckResult>;
|