geneseed 3.16.0 → 3.18.0
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/CHANGELOG.md +45 -0
- package/README.md +17 -3
- package/SHIPPED.md +3 -3
- package/adapters/claude-code/skills/geneseed/SKILL.md +1 -1
- package/adapters/opencode/README.md +2 -2
- package/adapters/opencode/plugins/geneseed-context.js +31 -21
- package/adapters/opencode/plugins/geneseed-guard.js +35 -7
- package/bin/geneseed-cli.mjs +5 -0
- package/bin/geneseed.mjs +17 -0
- package/docs/concepts/exclusions.md +1 -1
- package/docs/concepts/hooks.md +1 -0
- package/docs/concepts/loop-notes.md +3 -0
- package/docs/concepts/loops.md +11 -0
- package/docs/concepts/skills.md +4 -0
- package/docs/extending.md +33 -0
- package/docs/guides/headless.md +1 -1
- package/docs/guides/project-context.md +2 -2
- package/docs/guides/read-non-markdown.md +2 -2
- package/docs/guides/uninstall.md +1 -1
- package/docs/guides/upgrade.md +1 -1
- package/docs/guides/wiki.md +4 -4
- package/docs/opencode-plugin-setup.md +1 -1
- package/docs/reference/env-context.md +2 -2
- package/docs/reference/plugin-context.md +4 -4
- package/docs/reference/plugin-guard.md +1 -1
- package/docs/token-footprint.md +18 -15
- package/docs/understand/take-it-out.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +4 -1
- package/js/build/args.mjs +22 -20
- package/js/build/bundle.mjs +1 -3
- package/js/build/emit-claude.mjs +5 -4
- package/js/build/emit-opencode.mjs +9 -2
- package/js/build/generate.mjs +6 -6
- package/js/build/render.mjs +19 -12
- package/js/build/source.mjs +82 -7
- package/js/build/stubs.mjs +35 -6
- package/js/build/themes.mjs +1 -3
- package/js/cli-table.json +53 -0
- package/js/hosts/hooks-context.mjs +40 -27
- package/js/hosts/hooks.mjs +34 -1
- package/js/hosts/hosts.mjs +1 -3
- package/js/hosts/installs.mjs +13 -7
- package/js/hosts/mcp.mjs +1 -1
- package/js/hosts/native.mjs +60 -5
- package/js/hosts/opencode.mjs +37 -6
- package/js/hosts/settings.mjs +3 -1
- package/js/hosts/shim.mjs +1 -0
- package/js/inspect/checks-authoring.mjs +4 -1
- package/js/inspect/checks-constitution.mjs +2 -2
- package/js/inspect/checks-repo.mjs +164 -2
- package/js/inspect/diff.mjs +5 -1
- package/js/inspect/doctor.mjs +2 -1
- package/js/inspect/inventory.mjs +7 -9
- package/js/inspect/scorecard-checks.mjs +571 -0
- package/js/inspect/scorecard-svg.mjs +85 -0
- package/js/inspect/scorecard.mjs +839 -0
- package/js/lib/json.mjs +1 -1
- package/js/loop/graph.mjs +2 -1
- package/js/loop/state.mjs +30 -1
- package/js/maintain/migrate.mjs +2 -1
- package/js/maintain/uninstall.mjs +2 -3
- package/js/maintain/update.mjs +10 -3
- package/js/ui/menu.mjs +2 -2
- package/js/ui/tui.mjs +1 -0
- package/js/web/api.mjs +2 -0
- package/js/web/catalog.mjs +24 -7
- package/js/web/daemon.mjs +2 -1
- package/js/web/routes.mjs +2 -0
- package/js/web/server.mjs +1 -1
- package/js/web/user-files.mjs +1 -0
- package/package.json +11 -3
- package/registry.json +12 -75
- package/src/AGENT.md.tmpl +13 -22
- package/src/agents/docs.md +5 -0
- package/src/agents/researcher.md +1 -1
- package/src/bricks/apply.md +3 -0
- package/src/bricks/bruno-test.md +2 -2
- package/src/bricks/ci-fix.md +3 -3
- package/src/bricks/deps-audit.md +3 -3
- package/src/bricks/docs-update.md +2 -0
- package/src/bricks/migration-plan.md +2 -2
- package/src/bricks/test.md +17 -7
- package/src/doctrines/process.md +11 -7
- package/src/laws/universal.md +2 -2
- package/src/loops/api-endpoint.json +1 -1
- package/src/loops/bugfix.json +1 -1
- package/src/loops/ci-repair.json +1 -1
- package/src/loops/deps-upgrade.json +1 -1
- package/src/loops/enforce-architecture-rule.json +1 -1
- package/src/loops/feature.json +1 -1
- package/src/loops/fix-flaky-tests.json +1 -1
- package/src/loops/legacy-refactor.json +1 -1
- package/src/loops/legacy-tests.json +1 -1
- package/src/loops/refactor.json +1 -1
- package/src/loops/remove-dead-code.json +1 -1
- package/src/loops/spec-first-feature.json +1 -1
- package/src/loops/tdd.json +1 -1
- package/src/loops/update-contract.json +1 -1
- package/src/skills/brainstorm.md +14 -10
- package/src/skills/bruno.md +84 -0
- package/src/skills/codebase-design.md +41 -23
- package/src/skills/daydream/SKILL.md +3 -3
- package/src/skills/daydream/VENDOR.md +2 -2
- package/src/skills/daydream/instructions.md +1 -1
- package/src/skills/debug/ci.md +11 -0
- package/src/skills/debug.md +11 -8
- package/src/skills/dependencies.md +65 -0
- package/src/skills/develop.md +9 -4
- package/src/skills/document-project.md +106 -32
- package/src/skills/geneseed-code-review.md +13 -8
- package/src/skills/geneseed.md +4 -1
- package/src/skills/git-history.md +78 -0
- package/src/skills/handoff.md +4 -3
- package/src/skills/parallel-agents.md +1 -0
- package/src/skills/plan.md +5 -1
- package/src/skills/prototype.md +10 -7
- package/src/skills/research.md +6 -1
- package/src/skills/rule/consolidate.md +32 -0
- package/src/skills/rule.md +14 -4
- package/src/skills/security-audit.md +1 -1
- package/src/skills/ship.md +8 -5
- package/src/skills/teach.md +5 -3
- package/src/skills/wayfinder.md +19 -16
- package/src/skills/wiki.md +2 -2
- package/themes/_TEMPLATE.json +7 -16
- package/themes/biker.json +7 -16
- package/themes/commentator.json +7 -16
- package/themes/cyberpunk.json +7 -16
- package/themes/gamer.json +7 -16
- package/themes/imperial.json +7 -16
- package/themes/joker.json +7 -16
- package/themes/marvin.json +7 -16
- package/themes/mean.json +7 -16
- package/themes/military.json +7 -16
- package/themes/neutral.json +7 -16
- package/themes/pirate.json +7 -16
- package/themes/sports.json +7 -16
- package/themes/verstappen.json +7 -16
- package/themes/wizard.json +7 -16
- package/web/dist/assets/{Activity-kpWxd70F.js → Activity-4M4VQJOh.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Ccr942VL.js → ActivityDetail-CnmDs43F.js} +1 -1
- package/web/dist/assets/{Dashboard-D1lE7jcg.js → Dashboard-lTaH58R9.js} +2 -2
- package/web/dist/assets/{Docs-BHyaggFy.js → Docs-DU5znu5f.js} +1 -1
- package/web/dist/assets/FilterInput-BSfpStOZ.js +1 -0
- package/web/dist/assets/{GreenhouseView-Sc_UDMOE.js → GreenhouseView-BxO84bKX.js} +1 -1
- package/web/dist/assets/{Installs-Gw-pJ3Zi.js → Installs-B1WcObxK.js} +1 -1
- package/web/dist/assets/{JournalView-_gxpsxXi.js → JournalView-0YNKcaaD.js} +1 -1
- package/web/dist/assets/{Laws-S9fq1r9T.js → Laws-DnDpRwpE.js} +1 -1
- package/web/dist/assets/Library-DBkKsBMe.js +1 -0
- package/web/dist/assets/{Loops-Da_JhlzM.js → Loops-P-6RCdzS.js} +1 -1
- package/web/dist/assets/Markdown-C6fRd4A4.js +67 -0
- package/web/dist/assets/{OperatorHudView--TkvDybH.js → OperatorHudView-BZZ3uJPH.js} +1 -1
- package/web/dist/assets/{Personal-CTXKHVbV.js → Personal-BoVw6OCW.js} +1 -1
- package/web/dist/assets/{Tabs-B0cdf8k6.js → Tabs-VYLhUTiX.js} +1 -1
- package/web/dist/assets/{activity-Dr7ZMBI3.js → activity-dcg5vF9j.js} +1 -1
- package/web/dist/assets/{drawable-BbWQxe82.js → drawable-CdNARIKC.js} +1 -1
- package/web/dist/assets/{index-ujKG1ZNf.js → index-hsabpYzv.js} +2 -2
- package/web/dist/assets/react-DRhoX0kS.js +9 -0
- package/web/dist/assets/{usePoll-DD8VjtSg.js → usePoll-BmR631BB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/src/skills/bruno-collection-generator.md +0 -52
- package/src/skills/bruno-test-writer.md +0 -51
- package/src/skills/ci-fix.md +0 -49
- package/src/skills/consolidate-memory.md +0 -50
- package/src/skills/deps-audit.md +0 -46
- package/src/skills/domain-modeling.md +0 -27
- package/src/skills/git-archaeology.md +0 -36
- package/src/skills/git-rescue.md +0 -58
- package/src/skills/migrate.md +0 -34
- package/src/skills/opencode-theme.md +0 -42
- package/src/skills/repo-map.md +0 -25
- package/src/skills/tickets.md +0 -33
- package/web/dist/assets/FilterInput-CVyqmYWM.js +0 -1
- package/web/dist/assets/Library-MBxadRzn.js +0 -1
- package/web/dist/assets/Markdown-X2a3qvOh.js +0 -38
- package/web/dist/assets/react-B9Z_tXRV.js +0 -8
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `geneseed scorecard` — the AI Harness Scorecard, ported to JavaScript.
|
|
3
|
+
*
|
|
4
|
+
* UPSTREAM: markmishaev76/ai-harness-scorecard, pinned at commit
|
|
5
|
+
* 5536e96227dc8278301eaff3f4ca3faaf13cf278, MIT License, "Copyright (c) 2026 Mark Mishaev".
|
|
6
|
+
* This file ports `repo_context.py`, `ci_parser.py`, `scanner.py`, `models.py` and the JSON
|
|
7
|
+
* reporter; `scorecard-checks.mjs` ports `checks/*.py`. Same 31 check ids, names, points,
|
|
8
|
+
* partial scores, evidence and remediation strings, same category weights and grade thresholds.
|
|
9
|
+
*
|
|
10
|
+
* WHY A PORT AND NOT THE PYTHON TOOL. The CLI runs where Python may not (`ci.yml`'s
|
|
11
|
+
* `package-no-python` job proves the package needs none), and doctor's ratchet has to run
|
|
12
|
+
* in-process: the CLI is under a transitive spawn ban. Upstream uses no subprocess and no
|
|
13
|
+
* network, and neither does this.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IS DELIBERATELY DIFFERENT, and only this:
|
|
16
|
+
* * Paths are normalised to `/`. Upstream keeps the OS separator, so on Windows its evidence
|
|
17
|
+
* says `docs\x.md` where this says `docs/x.md`. Matching is unaffected: Python's `fnmatch`
|
|
18
|
+
* normcases both sides on Windows, and its `*` crosses separators — which the java
|
|
19
|
+
* indicator `*` + `/pom.xml` matching a pom.xml at ANY depth relies on, and `fnmatch` keeps.
|
|
20
|
+
* * The file list is sorted by the `/` spelling, i.e. upstream's order on POSIX. Only the
|
|
21
|
+
* first-match evidence strings can tell, never a score.
|
|
22
|
+
* * YAML is read by the small subset parser below, not PyYAML — see its `ponytail:` note.
|
|
23
|
+
*/
|
|
24
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { isDir, isFile, printErr, printOut, readText, writeText } from '../lib/fs.mjs';
|
|
28
|
+
import { ALL_CHECKS } from './scorecard-checks.mjs';
|
|
29
|
+
import { badgeUrl, renderSvg } from './scorecard-svg.mjs';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------------------------
|
|
32
|
+
// The YAML subset
|
|
33
|
+
// ---------------------------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
class YamlError extends Error {}
|
|
36
|
+
|
|
37
|
+
/** A flow mapping (`{a: b}`), kept as its source text — the subset does not look inside one. */
|
|
38
|
+
export class FlowMap {
|
|
39
|
+
constructor(raw) { this.raw = raw; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NULLS = new Set(['', '~', 'null', 'Null', 'NULL']);
|
|
43
|
+
const TRUES = new Set(['yes', 'Yes', 'YES', 'true', 'True', 'TRUE', 'on', 'On', 'ON']);
|
|
44
|
+
const FALSES = new Set(['no', 'No', 'NO', 'false', 'False', 'FALSE', 'off', 'Off', 'OFF']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* PyYAML's YAML 1.1 resolver for a plain scalar: null, the eighteen booleans (`on` is `True`,
|
|
48
|
+
* which is why upstream looks the GitHub trigger key up under both `"on"` and `True`), ints and
|
|
49
|
+
* floats. Everything else stays a string.
|
|
50
|
+
*/
|
|
51
|
+
function resolvePlain(s) {
|
|
52
|
+
if (NULLS.has(s)) return null;
|
|
53
|
+
if (TRUES.has(s)) return true;
|
|
54
|
+
if (FALSES.has(s)) return false;
|
|
55
|
+
if (/^[-+]?(0|[1-9][0-9_]*)$/.test(s)) return Number(s.replaceAll('_', ''));
|
|
56
|
+
if (/^[-+]?([0-9][0-9_]*)?\.[0-9_]*([eE][-+][0-9]+)?$/.test(s) && /[0-9]/.test(s)) {
|
|
57
|
+
return Number(s.replaceAll('_', ''));
|
|
58
|
+
}
|
|
59
|
+
return s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `str(value)` for what the subset produces. A collection prints as Python's `repr`, because
|
|
64
|
+
* that is the command text upstream matches: `- echo "a: b"` in a GitLab script is a MAPPING
|
|
65
|
+
* to YAML, and `str()` of it is `{'echo "a': 'b"'}`.
|
|
66
|
+
*/
|
|
67
|
+
export function pyStr(v) {
|
|
68
|
+
if (typeof v === 'string') return v;
|
|
69
|
+
if (v === null || v === undefined) return 'None';
|
|
70
|
+
if (v === true) return 'True';
|
|
71
|
+
if (v === false) return 'False';
|
|
72
|
+
if (v instanceof FlowMap) return v.raw;
|
|
73
|
+
if (Array.isArray(v)) return `[${v.map(pyRepr).join(', ')}]`;
|
|
74
|
+
if (v instanceof Map) return `{${[...v].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(', ')}}`;
|
|
75
|
+
return String(v);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pyRepr(v) {
|
|
79
|
+
if (typeof v !== 'string') return pyStr(v);
|
|
80
|
+
const q = v.includes("'") && !v.includes('"') ? '"' : "'";
|
|
81
|
+
const body = v.replace(/[\\\n\r\t]/g, (c) => ({ '\\': '\\\\', '\n': '\\n', '\r': '\\r', '\t': '\\t' })[c])
|
|
82
|
+
.replaceAll(q, `\\${q}`);
|
|
83
|
+
return `${q}${body}${q}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `bool(value)` — `allow_failure: {exit_codes: [1]}` and `"false"` are both TRUE upstream. */
|
|
87
|
+
export function pyTruthy(v) {
|
|
88
|
+
if (v === null || v === undefined || v === false) return false;
|
|
89
|
+
if (typeof v === 'number') return v !== 0;
|
|
90
|
+
if (typeof v === 'string' || Array.isArray(v)) return v.length > 0;
|
|
91
|
+
if (v instanceof Map) return v.size > 0;
|
|
92
|
+
if (v instanceof FlowMap) return v.raw.replace(/\s/g, '') !== '{}';
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fold the line breaks inside a multi-line flow or quoted scalar: one break is a space. */
|
|
97
|
+
function foldBreaks(s) {
|
|
98
|
+
return s.replace(/[ \t]*\n([ \t]*\n)*[ \t]*/g, (m) => {
|
|
99
|
+
const n = m.split('\n').length - 1;
|
|
100
|
+
return n === 1 ? ' ' : '\n'.repeat(n - 1);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ESCAPES = { n: '\n', t: '\t', r: '\r', 0: '\0', '"': '"', '\\': '\\', '/': '/', ' ': ' ', e: '\x1b', a: '\x07', b: '\b', f: '\f', v: '\v', N: '\x85', _: '\xa0' };
|
|
105
|
+
|
|
106
|
+
function unescapeDouble(s) {
|
|
107
|
+
return s.replace(/\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)/gs, (m, e) => {
|
|
108
|
+
if (e.length > 1) return String.fromCodePoint(parseInt(e.slice(1), 16));
|
|
109
|
+
if (e in ESCAPES) return ESCAPES[e];
|
|
110
|
+
throw new YamlError(`unknown escape \\${e}`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Index just past the closing quote of the quoted scalar starting at `s[0]`, or -1. */
|
|
115
|
+
function quoteEnd(s) {
|
|
116
|
+
const q = s[0];
|
|
117
|
+
for (let i = 1; i < s.length; i += 1) {
|
|
118
|
+
if (q === '"' && s[i] === '\\') { i += 1; continue; }
|
|
119
|
+
if (s[i] === q) {
|
|
120
|
+
if (q === "'" && s[i + 1] === "'") { i += 1; continue; }
|
|
121
|
+
return i + 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return -1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function quotedValue(span) {
|
|
128
|
+
const body = span.slice(1, -1);
|
|
129
|
+
if (span[0] === "'") return foldBreaks(body).replaceAll("''", "'");
|
|
130
|
+
// A `\` before a line break escapes the break itself: no space is folded in for it.
|
|
131
|
+
return unescapeDouble(foldBreaks(body.replace(/(?<!\\)\\\n[ \t]*/g, '')));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Index just past the bracket that closes `s[0]`, honouring quotes; -1 while still open. */
|
|
135
|
+
function flowEnd(s) {
|
|
136
|
+
let depth = 0;
|
|
137
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
138
|
+
const c = s[i];
|
|
139
|
+
if ((c === '"' || c === "'") && /^$|[\s[{,]/.test(s[i - 1] ?? '')) {
|
|
140
|
+
const e = quoteEnd(s.slice(i));
|
|
141
|
+
if (e < 0) return -1;
|
|
142
|
+
i += e - 1;
|
|
143
|
+
} else if (c === '[' || c === '{') depth += 1;
|
|
144
|
+
else if (c === ']' || c === '}') { depth -= 1; if (depth === 0) return i + 1; }
|
|
145
|
+
}
|
|
146
|
+
return -1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A flow sequence of scalars becomes an array; a flow mapping anywhere stays opaque. */
|
|
150
|
+
function parseFlow(src) {
|
|
151
|
+
if (src[0] === '{') return new FlowMap(src);
|
|
152
|
+
const items = [];
|
|
153
|
+
let i = 1;
|
|
154
|
+
while (i < src.length - 1) {
|
|
155
|
+
while (/[\s,]/.test(src[i]) && i < src.length - 1) i += 1;
|
|
156
|
+
if (i >= src.length - 1) break;
|
|
157
|
+
const rest = src.slice(i, -1);
|
|
158
|
+
let end;
|
|
159
|
+
if (rest[0] === '[' || rest[0] === '{') {
|
|
160
|
+
end = flowEnd(rest);
|
|
161
|
+
items.push(parseFlow(rest.slice(0, end)));
|
|
162
|
+
} else if (rest[0] === '"' || rest[0] === "'") {
|
|
163
|
+
end = quoteEnd(rest);
|
|
164
|
+
items.push(quotedValue(rest.slice(0, end)));
|
|
165
|
+
} else {
|
|
166
|
+
end = rest.search(/,|$/);
|
|
167
|
+
items.push(resolvePlain(foldBreaks(rest.slice(0, end)).trim()));
|
|
168
|
+
}
|
|
169
|
+
i += end;
|
|
170
|
+
}
|
|
171
|
+
return items;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The CI-config YAML subset upstream's checks read, parsed into Maps, arrays and scalars.
|
|
176
|
+
*
|
|
177
|
+
* Returns the document, or `undefined` where PyYAML's `safe_load` would RAISE — and upstream
|
|
178
|
+
* then drops the whole file (`_load_yaml` answers `None`), so a raise is behaviour to keep, not
|
|
179
|
+
* an error to paper over. Raised here: a second document, a local tag such as GitLab's
|
|
180
|
+
* `!reference` (unknown to `safe_load`), a plain value holding `: `, a tab used as indentation.
|
|
181
|
+
*
|
|
182
|
+
* Handles block mappings and sequences (including a sequence at its key's own indent, and a
|
|
183
|
+
* mapping that starts on the `- ` line), `|`/`>` block scalars with chomping and indentation
|
|
184
|
+
* indicators, plain scalars (multi-line, ` #` comments), single- and double-quoted scalars
|
|
185
|
+
* (escapes, multi-line), flow sequences of scalars, `&anchor` prefixes and `!!` tags.
|
|
186
|
+
*
|
|
187
|
+
* ponytail: a subset, not YAML. Its ceiling: flow MAPPINGS are kept opaque (`FlowMap`), so a
|
|
188
|
+
* job or rule written `{...}` is invisible; aliases (`*x`) and merge keys (`<<: *x`) are not
|
|
189
|
+
* resolved — an alias reads as the string `*x` — which is the likeliest real miss on GitLab
|
|
190
|
+
* files that share `before_script` through anchors; `? ` complex keys are refused;
|
|
191
|
+
* multi-document files are refused as PyYAML refuses them. Upgrade path: vendor a YAML parser
|
|
192
|
+
* under `js/vendor/` (CLAUDE.md: never install one) once a scored repo needs any of those.
|
|
193
|
+
*/
|
|
194
|
+
export function parseYamlSubset(text) {
|
|
195
|
+
try {
|
|
196
|
+
return new YamlParser(text).document();
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (e instanceof YamlError) return undefined;
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
class YamlParser {
|
|
204
|
+
constructor(text) {
|
|
205
|
+
this.raw = text.replace(/^\uFEFF/, '').split('\n');
|
|
206
|
+
this.lines = this.raw.map((r) => {
|
|
207
|
+
const indent = r.length - r.replace(/^ +/, '').length;
|
|
208
|
+
const text = r.slice(indent).replace(/\s+$/, '');
|
|
209
|
+
// A tab where indentation goes is a YAML error — raised by `peek`, on content lines only.
|
|
210
|
+
return { indent, text: text.replace(/^\t+/, ''), tab: text.startsWith('\t') };
|
|
211
|
+
});
|
|
212
|
+
this.pos = 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
document() {
|
|
216
|
+
let started = false;
|
|
217
|
+
let i = 0;
|
|
218
|
+
for (; i < this.lines.length; i += 1) {
|
|
219
|
+
const { indent, text } = this.lines[i];
|
|
220
|
+
if (indent === 0 && /^---(\s|$)/.test(text)) {
|
|
221
|
+
if (started) throw new YamlError('a second document');
|
|
222
|
+
started = true;
|
|
223
|
+
this.lines[i] = { indent: 0, text: '' };
|
|
224
|
+
} else if (indent === 0 && /^\.\.\.(\s|$)/.test(text)) {
|
|
225
|
+
this.lines.length = i;
|
|
226
|
+
break;
|
|
227
|
+
} else if (indent === 0 && text.startsWith('%') && !started) {
|
|
228
|
+
this.lines[i] = { indent: 0, text: '' };
|
|
229
|
+
} else if (text && !text.startsWith('#')) {
|
|
230
|
+
started = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const value = this.node(0);
|
|
234
|
+
if (this.peek() !== null) throw new YamlError('content after the document');
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Index of the next line that is neither blank nor a comment, or null. */
|
|
239
|
+
peek() {
|
|
240
|
+
for (let i = this.pos; i < this.lines.length; i += 1) {
|
|
241
|
+
const { text } = this.lines[i];
|
|
242
|
+
if (text && !text.startsWith('#')) {
|
|
243
|
+
if (this.lines[i].tab) throw new YamlError('a tab as indentation');
|
|
244
|
+
this.pos = i;
|
|
245
|
+
return i;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
this.pos = this.lines.length;
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
node(minIndent) {
|
|
253
|
+
const i = this.peek();
|
|
254
|
+
if (i === null || this.lines[i].indent < minIndent) return null;
|
|
255
|
+
const { indent, text } = this.lines[i];
|
|
256
|
+
if (isSeqItem(text)) return this.seq(indent);
|
|
257
|
+
if (splitKey(text)) return this.map(indent);
|
|
258
|
+
this.pos = i + 1;
|
|
259
|
+
return this.value(text, indent - 1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The value of `key:` with nothing after it — or of a bare `-`. */
|
|
263
|
+
nested(indent, sameIndentSeq) {
|
|
264
|
+
const i = this.peek();
|
|
265
|
+
if (i === null) return null;
|
|
266
|
+
const ln = this.lines[i];
|
|
267
|
+
if (sameIndentSeq && ln.indent === indent && isSeqItem(ln.text)) return this.seq(indent);
|
|
268
|
+
return ln.indent > indent ? this.node(ln.indent) : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
map(indent) {
|
|
272
|
+
const m = new Map();
|
|
273
|
+
for (let i = this.peek(); i !== null; i = this.peek()) {
|
|
274
|
+
const ln = this.lines[i];
|
|
275
|
+
if (ln.indent < indent || (ln.indent === indent && isSeqItem(ln.text))) break;
|
|
276
|
+
if (ln.indent > indent) throw new YamlError('bad indentation');
|
|
277
|
+
const kv = splitKey(ln.text);
|
|
278
|
+
if (!kv) throw new YamlError('expected a key');
|
|
279
|
+
this.pos = i + 1;
|
|
280
|
+
// Python dicts keep the FIRST position of a duplicate key and the LAST value; so do Maps.
|
|
281
|
+
m.set(kv.key, kv.rest === '' || kv.rest.startsWith('#')
|
|
282
|
+
? this.nested(indent, true) : this.value(kv.rest, indent));
|
|
283
|
+
}
|
|
284
|
+
return m;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
seq(indent) {
|
|
288
|
+
const arr = [];
|
|
289
|
+
for (let i = this.peek(); i !== null; i = this.peek()) {
|
|
290
|
+
const ln = this.lines[i];
|
|
291
|
+
if (ln.indent < indent || !isSeqItem(ln.text)) break;
|
|
292
|
+
if (ln.indent > indent) throw new YamlError('bad indentation');
|
|
293
|
+
const after = ln.text.slice(1);
|
|
294
|
+
const body = after.trimStart();
|
|
295
|
+
if (body === '' || body.startsWith('#')) {
|
|
296
|
+
this.pos = i + 1;
|
|
297
|
+
arr.push(this.nested(indent, false));
|
|
298
|
+
} else {
|
|
299
|
+
// `- key: v` opens a mapping whose keys sit at the column `key` starts in: re-read the
|
|
300
|
+
// rest of this line as a node of its own at that column.
|
|
301
|
+
const col = indent + 1 + (after.length - body.length);
|
|
302
|
+
this.lines[i] = { indent: col, text: body };
|
|
303
|
+
arr.push(this.node(col));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return arr;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A value that starts on the current line, after `key: ` or `- `. */
|
|
310
|
+
value(rest, parentIndent) {
|
|
311
|
+
let s = rest;
|
|
312
|
+
for (;;) {
|
|
313
|
+
const anchor = /^&\S+\s*/.exec(s);
|
|
314
|
+
if (anchor) { s = s.slice(anchor[0].length); continue; }
|
|
315
|
+
const tag = /^!(\S*)\s*/.exec(s);
|
|
316
|
+
if (tag) {
|
|
317
|
+
if (!tag[1].startsWith('!')) throw new YamlError(`unknown tag !${tag[1]}`);
|
|
318
|
+
s = s.slice(tag[0].length);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
if (s === '' || s.startsWith('#')) return this.nested(parentIndent, false);
|
|
324
|
+
const c = s[0];
|
|
325
|
+
if (c === '|' || c === '>') return this.block(s, parentIndent);
|
|
326
|
+
if (c === '"' || c === "'" || c === '[' || c === '{') {
|
|
327
|
+
let span = s;
|
|
328
|
+
let end = c === '[' || c === '{' ? flowEnd(span) : quoteEnd(span);
|
|
329
|
+
while (end < 0 && this.pos < this.lines.length) {
|
|
330
|
+
span += `\n${this.raw[this.pos].trim()}`;
|
|
331
|
+
this.pos += 1;
|
|
332
|
+
end = c === '[' || c === '{' ? flowEnd(span) : quoteEnd(span);
|
|
333
|
+
}
|
|
334
|
+
if (end < 0) throw new YamlError('an unterminated scalar');
|
|
335
|
+
const tail = span.slice(end).trim();
|
|
336
|
+
if (tail && !tail.startsWith('#')) throw new YamlError('text after a closed scalar');
|
|
337
|
+
return c === '[' || c === '{' ? parseFlow(span.slice(0, end)) : quotedValue(span.slice(0, end));
|
|
338
|
+
}
|
|
339
|
+
if (c === '*') return s.replace(/\s+#.*$/, '');
|
|
340
|
+
if (c === '@' || c === '`') throw new YamlError('a reserved indicator');
|
|
341
|
+
return resolvePlain(this.plain(s, parentIndent));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** A plain scalar and its continuation lines, folded. */
|
|
345
|
+
plain(first, parentIndent) {
|
|
346
|
+
const parts = [first.replace(/\s+#.*$/, '')];
|
|
347
|
+
let blanks = 0;
|
|
348
|
+
if (!/\s#/.test(first)) {
|
|
349
|
+
for (let j = this.pos; j < this.lines.length; j += 1) {
|
|
350
|
+
const { indent, text } = this.lines[j];
|
|
351
|
+
if (!text) { blanks += 1; continue; }
|
|
352
|
+
if (text.startsWith('#') || indent <= parentIndent) break;
|
|
353
|
+
parts.push(blanks ? '\n'.repeat(blanks) : ' ', text.replace(/\s+#.*$/, ''));
|
|
354
|
+
blanks = 0;
|
|
355
|
+
this.pos = j + 1;
|
|
356
|
+
if (/\s#/.test(text)) break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const out = parts.join('');
|
|
360
|
+
if (/:(\s|$)/.test(out)) throw new YamlError('mapping values are not allowed here');
|
|
361
|
+
return out.trim();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
block(header, parentIndent) {
|
|
365
|
+
const h = /^([|>])([1-9]?)([+-]?)([1-9]?)\s*(#.*)?$/.exec(header);
|
|
366
|
+
if (!h) throw new YamlError('a bad block scalar header');
|
|
367
|
+
const digit = Number(h[2] || h[4] || 0);
|
|
368
|
+
const chomp = h[3];
|
|
369
|
+
let contentIndent = digit ? parentIndent + digit : null;
|
|
370
|
+
const lines = [];
|
|
371
|
+
let j = this.pos;
|
|
372
|
+
for (; j < this.raw.length && j < this.lines.length; j += 1) {
|
|
373
|
+
const r = this.raw[j];
|
|
374
|
+
if (r.trim() === '') { lines.push(''); continue; }
|
|
375
|
+
const ind = r.length - r.replace(/^ +/, '').length;
|
|
376
|
+
if (contentIndent === null) {
|
|
377
|
+
if (ind <= parentIndent) break;
|
|
378
|
+
contentIndent = ind;
|
|
379
|
+
}
|
|
380
|
+
if (ind < contentIndent) break;
|
|
381
|
+
lines.push(r.slice(contentIndent));
|
|
382
|
+
}
|
|
383
|
+
this.pos = j;
|
|
384
|
+
let trailing = 0;
|
|
385
|
+
while (lines.length && lines[lines.length - 1] === '') { lines.pop(); trailing += 1; }
|
|
386
|
+
let body;
|
|
387
|
+
if (h[1] === '|') {
|
|
388
|
+
body = lines.join('\n');
|
|
389
|
+
} else {
|
|
390
|
+
body = '';
|
|
391
|
+
let prevMore = null;
|
|
392
|
+
let empties = 0;
|
|
393
|
+
for (const l of lines) {
|
|
394
|
+
if (l === '') { empties += 1; continue; }
|
|
395
|
+
const more = /^[ \t]/.test(l);
|
|
396
|
+
if (prevMore === null) body += '\n'.repeat(empties);
|
|
397
|
+
else if (!more && !prevMore) body += empties ? '\n'.repeat(empties) : ' ';
|
|
398
|
+
else body += '\n'.repeat(empties + 1);
|
|
399
|
+
body += l;
|
|
400
|
+
prevMore = more;
|
|
401
|
+
empties = 0;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (chomp === '-' || body === '') return chomp === '+' ? body + '\n'.repeat(trailing) : body;
|
|
405
|
+
return chomp === '+' ? `${body}\n${'\n'.repeat(trailing)}` : `${body}\n`;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isSeqItem(text) {
|
|
410
|
+
return text === '-' || /^-[ \t]/.test(text);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** `key: rest` → `{ key, rest }`, or null when the line is not a mapping entry. */
|
|
414
|
+
function splitKey(text) {
|
|
415
|
+
if (isSeqItem(text) || text[0] === '[' || text[0] === '{') return null;
|
|
416
|
+
if (text.startsWith('? ')) throw new YamlError('a complex key');
|
|
417
|
+
if (text[0] === '"' || text[0] === "'") {
|
|
418
|
+
const end = quoteEnd(text);
|
|
419
|
+
if (end < 0) return null;
|
|
420
|
+
const m = /^\s*:(\s|$)/.exec(text.slice(end));
|
|
421
|
+
if (!m) return null;
|
|
422
|
+
return { key: quotedValue(text.slice(0, end)), rest: text.slice(end + m[0].length).trim() };
|
|
423
|
+
}
|
|
424
|
+
const m = /:(\s|$)/.exec(text);
|
|
425
|
+
if (!m) return null;
|
|
426
|
+
const hash = text.search(/\s#/);
|
|
427
|
+
if (hash >= 0 && hash < m.index) return null;
|
|
428
|
+
const raw = text.slice(0, m.index).trimEnd();
|
|
429
|
+
const key = resolvePlain(raw);
|
|
430
|
+
// Keys stay strings except the booleans and null, which `data.get(True)` must be able to find.
|
|
431
|
+
return { key: typeof key === 'number' ? raw : key, rest: text.slice(m.index + 1).trim() };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------------------------
|
|
435
|
+
// ci_parser.py
|
|
436
|
+
// ---------------------------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
const GITLAB_RESERVED_KEYS = new Set([
|
|
439
|
+
'stages', 'variables', 'default', 'include', 'workflow', 'image', 'services',
|
|
440
|
+
'before_script', 'after_script', 'cache', 'artifacts', '.pre', '.post',
|
|
441
|
+
]);
|
|
442
|
+
|
|
443
|
+
const get = (m, k, dflt) => (m.has(k) ? m.get(k) : dflt);
|
|
444
|
+
|
|
445
|
+
/** `_load_yaml` — `[raw, mapping]`, or `[raw|'', null]` for a non-mapping or a parse failure. */
|
|
446
|
+
function loadYaml(file) {
|
|
447
|
+
let raw;
|
|
448
|
+
try { raw = readText(file); } catch { return ['', null]; }
|
|
449
|
+
const data = parseYamlSubset(raw);
|
|
450
|
+
if (data === undefined) return ['', null];
|
|
451
|
+
return [raw, data instanceof Map ? data : null];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** `parse_ci_configs` — `.gitlab-ci.yml`, then `.github/workflows/*.yml`, then `*.yaml`. */
|
|
455
|
+
export function parseCiConfigs(root) {
|
|
456
|
+
const configs = [];
|
|
457
|
+
const gitlab = path.join(root, '.gitlab-ci.yml');
|
|
458
|
+
if (isFile(gitlab)) {
|
|
459
|
+
const c = parseGitlabCi(...loadYaml(gitlab));
|
|
460
|
+
if (c) configs.push(c);
|
|
461
|
+
}
|
|
462
|
+
const wf = path.join(root, '.github', 'workflows');
|
|
463
|
+
if (isDir(wf)) {
|
|
464
|
+
let names = [];
|
|
465
|
+
try { names = readdirSync(wf); } catch { /* unreadable: no workflows */ }
|
|
466
|
+
for (const suffix of ['.yml', '.yaml']) {
|
|
467
|
+
for (const n of names.filter((x) => x.endsWith(suffix)).sort()) {
|
|
468
|
+
const c = parseGithubActions(...loadYaml(path.join(wf, n)));
|
|
469
|
+
if (c) configs.push(c);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return configs;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function parseGitlabCi(raw, data) {
|
|
477
|
+
if (data === null) return null;
|
|
478
|
+
const config = { ciType: 'gitlab', jobs: [], hasSchedule: false, rawContent: raw };
|
|
479
|
+
config.hasSchedule = gitlabHasSchedule(data);
|
|
480
|
+
for (const [key, value] of data) {
|
|
481
|
+
if (typeof key !== 'string' || key.startsWith('.') || GITLAB_RESERVED_KEYS.has(key)) continue;
|
|
482
|
+
if (!(value instanceof Map)) continue;
|
|
483
|
+
config.jobs.push({
|
|
484
|
+
name: key,
|
|
485
|
+
commands: gitlabCommands(value),
|
|
486
|
+
allowFailure: pyTruthy(get(value, 'allow_failure', false)),
|
|
487
|
+
stage: get(value, 'stage', null),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return config;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Every top-level mapping is scanned, reserved ones included — `workflow: rules:` counts. */
|
|
494
|
+
function gitlabHasSchedule(data) {
|
|
495
|
+
for (const value of data.values()) {
|
|
496
|
+
if (!(value instanceof Map)) continue;
|
|
497
|
+
const rules = get(value, 'rules', []);
|
|
498
|
+
if (!Array.isArray(rules)) continue;
|
|
499
|
+
for (const rule of rules) {
|
|
500
|
+
if (rule instanceof Map && pyStr(get(rule, 'if', '')).toLowerCase().includes('schedule')) {
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function gitlabCommands(job) {
|
|
509
|
+
const commands = [];
|
|
510
|
+
for (const key of ['before_script', 'script', 'after_script']) {
|
|
511
|
+
const s = get(job, key, []);
|
|
512
|
+
if (Array.isArray(s)) commands.push(...s.map(pyStr));
|
|
513
|
+
else if (typeof s === 'string') commands.push(s);
|
|
514
|
+
}
|
|
515
|
+
return commands;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function parseGithubActions(raw, data) {
|
|
519
|
+
if (data === null) return null;
|
|
520
|
+
// `data.get("on")`, then `data.get(True)`: YAML 1.1 reads a bare `on:` key as the boolean.
|
|
521
|
+
let on = get(data, 'on', null);
|
|
522
|
+
if (on === null) on = get(data, true, null);
|
|
523
|
+
const config = {
|
|
524
|
+
ciType: 'github', jobs: [], hasSchedule: on instanceof Map && on.has('schedule'), rawContent: raw,
|
|
525
|
+
};
|
|
526
|
+
const jobs = get(data, 'jobs', new Map());
|
|
527
|
+
if (!(jobs instanceof Map)) return config;
|
|
528
|
+
for (const [name, job] of jobs) {
|
|
529
|
+
if (!(job instanceof Map)) continue;
|
|
530
|
+
const commands = [];
|
|
531
|
+
const uses = job.get('uses');
|
|
532
|
+
if (typeof uses === 'string') commands.push(`uses: ${uses}`);
|
|
533
|
+
const steps = get(job, 'steps', []);
|
|
534
|
+
for (const step of Array.isArray(steps) ? steps : []) {
|
|
535
|
+
if (!(step instanceof Map)) continue;
|
|
536
|
+
if (step.has('run')) commands.push(pyStr(step.get('run')));
|
|
537
|
+
if (step.has('uses')) commands.push(`uses: ${pyStr(step.get('uses'))}`);
|
|
538
|
+
}
|
|
539
|
+
config.jobs.push({
|
|
540
|
+
name: pyStr(name), commands, allowFailure: pyTruthy(get(job, 'continue-on-error', false)), stage: null,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return config;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------------------------
|
|
547
|
+
// repo_context.py
|
|
548
|
+
// ---------------------------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
const LANGUAGE_INDICATORS = {
|
|
551
|
+
rust: ['Cargo.toml'],
|
|
552
|
+
python: ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'],
|
|
553
|
+
javascript: ['package.json'],
|
|
554
|
+
typescript: ['tsconfig.json'],
|
|
555
|
+
go: ['go.mod'],
|
|
556
|
+
java: ['pom.xml', '*/pom.xml', 'build.gradle', '*/build.gradle', 'build.gradle.kts', '*/build.gradle.kts'],
|
|
557
|
+
ruby: ['Gemfile'],
|
|
558
|
+
csharp: ['*.csproj', '*.sln'],
|
|
559
|
+
swift: ['Package.swift'],
|
|
560
|
+
kotlin: ['build.gradle.kts', '*/build.gradle.kts'],
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const IGNORED_DIRS = new Set([
|
|
564
|
+
'.git', 'node_modules', 'target', '__pycache__', '.venv', 'venv', 'dist', 'build', '.tox',
|
|
565
|
+
'.mypy_cache', '.pytest_cache', 'vendor', '.bundle', '.cargo',
|
|
566
|
+
]);
|
|
567
|
+
|
|
568
|
+
const FNMATCH_CACHE = new Map();
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* `fnmatch(path.lower(), pattern.lower())`: anchored, `*` crossing `/`, `?` one character.
|
|
572
|
+
*
|
|
573
|
+
* ponytail: no `[...]` classes — none of the 31 checks' patterns uses one. Add the class branch
|
|
574
|
+
* of `fnmatch.translate` when a pattern needs it.
|
|
575
|
+
*/
|
|
576
|
+
function fnmatch(name, pattern) {
|
|
577
|
+
let re = FNMATCH_CACHE.get(pattern);
|
|
578
|
+
if (!re) {
|
|
579
|
+
const src = pattern.toLowerCase().replace(/[.+^${}()|[\]\\/]/g, '\\$&')
|
|
580
|
+
.replace(/\*+/g, '.*').replace(/\?/g, '.');
|
|
581
|
+
re = new RegExp(`^(?:${src})$`, 's');
|
|
582
|
+
FNMATCH_CACHE.set(pattern, re);
|
|
583
|
+
}
|
|
584
|
+
return re.test(name.toLowerCase());
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** `os.walk` minus `IGNORED_DIRS` at every depth; a symlinked directory is listed but not entered. */
|
|
588
|
+
function scanFileTree(root) {
|
|
589
|
+
const files = [];
|
|
590
|
+
const walk = (dir, rel) => {
|
|
591
|
+
let entries;
|
|
592
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
593
|
+
for (const e of entries) {
|
|
594
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
595
|
+
if (e.isDirectory()) {
|
|
596
|
+
if (!IGNORED_DIRS.has(e.name)) walk(path.join(dir, e.name), r);
|
|
597
|
+
} else if (e.isSymbolicLink()) {
|
|
598
|
+
let d = false;
|
|
599
|
+
try { d = statSync(path.join(dir, e.name)).isDirectory(); } catch { /* broken: a file */ }
|
|
600
|
+
if (!d) files.push(r);
|
|
601
|
+
} else {
|
|
602
|
+
files.push(r);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
walk(root, '');
|
|
607
|
+
return files.sort();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** `RepoContext` — the pre-scanned repo every check reads. */
|
|
611
|
+
export class RepoContext {
|
|
612
|
+
constructor(root) {
|
|
613
|
+
this.path = path.resolve(root);
|
|
614
|
+
this.fileTree = scanFileTree(this.path);
|
|
615
|
+
this.languages = Object.keys(LANGUAGE_INDICATORS)
|
|
616
|
+
.filter((lang) => LANGUAGE_INDICATORS[lang].some((p) => this.hasFile(p))).sort();
|
|
617
|
+
this.ciConfigs = parseCiConfigs(this.path);
|
|
618
|
+
this.scripts = this.loadScripts();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
hasFile(...patterns) {
|
|
622
|
+
for (const p of patterns) {
|
|
623
|
+
for (const f of this.fileTree) if (fnmatch(f, p)) return f;
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
findFiles(...patterns) {
|
|
629
|
+
const out = [];
|
|
630
|
+
for (const p of patterns) for (const f of this.fileTree) if (fnmatch(f, p)) out.push(f);
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
hasDir(...patterns) {
|
|
635
|
+
return patterns.find((p) => isDir(path.join(this.path, p))) ?? null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
readFile(rel) {
|
|
639
|
+
const full = path.join(this.path, rel);
|
|
640
|
+
if (!isFile(full)) return null;
|
|
641
|
+
try { return readText(full); } catch { return null; }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
searchFile(rel, pattern) {
|
|
645
|
+
const content = this.readFile(rel);
|
|
646
|
+
return content !== null && new RegExp(pattern, 'im').test(content);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
searchAnyFile(filePatterns, textPattern) {
|
|
650
|
+
for (const fp of filePatterns) {
|
|
651
|
+
for (const f of this.fileTree) {
|
|
652
|
+
if (fnmatch(f, fp) && this.searchFile(f, textPattern)) return f;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
ciHasCommand(pattern) {
|
|
659
|
+
const re = new RegExp(pattern, 'i');
|
|
660
|
+
return this.ciConfigs.some((ci) => ci.jobs.some((j) => this.matchesCommand(j.commands, re)));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
ciHasBlockingCommand(pattern) {
|
|
664
|
+
const re = new RegExp(pattern, 'i');
|
|
665
|
+
return this.ciConfigs.some((ci) => ci.jobs.some(
|
|
666
|
+
(j) => !j.allowFailure && this.matchesCommand(j.commands, re),
|
|
667
|
+
));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
matchesCommand(commands, re) {
|
|
671
|
+
return commands.some((cmd) => {
|
|
672
|
+
if (re.test(cmd)) return true;
|
|
673
|
+
const resolved = this.resolveScript(cmd);
|
|
674
|
+
return Boolean(resolved) && re.test(resolved);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
ciHasScheduledJob() {
|
|
679
|
+
return this.ciConfigs.some((ci) => ci.hasSchedule);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
ciRawContent() {
|
|
683
|
+
return this.ciConfigs.map((ci) => ci.rawContent).join('\n');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
loadScripts() {
|
|
687
|
+
const content = this.readFile('package.json');
|
|
688
|
+
if (!content) return new Map();
|
|
689
|
+
let data;
|
|
690
|
+
try { data = JSON.parse(content); } catch { return new Map(); }
|
|
691
|
+
const scripts = data && typeof data === 'object' && !Array.isArray(data) ? data.scripts : null;
|
|
692
|
+
if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) return new Map();
|
|
693
|
+
return new Map(Object.entries(scripts).map(([k, v]) => [k, pyStr(v)]));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** `npm run X` / `yarn X` / `pnpm X` / `bun X` → the body of package.json's script X. */
|
|
697
|
+
resolveScript(command) {
|
|
698
|
+
const parts = command.split(/\s+/).filter(Boolean);
|
|
699
|
+
if (parts.length < 2) return null;
|
|
700
|
+
const pm = parts[0].toLowerCase();
|
|
701
|
+
if (!['npm', 'yarn', 'pnpm', 'bun'].includes(pm)) return null;
|
|
702
|
+
let name = null;
|
|
703
|
+
if (parts[1].toLowerCase() === 'run') {
|
|
704
|
+
if (parts.length >= 3) name = parts[2];
|
|
705
|
+
} else if (pm !== 'npm') {
|
|
706
|
+
name = parts[1];
|
|
707
|
+
}
|
|
708
|
+
return name && this.scripts.has(name) ? this.scripts.get(name) : null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ---------------------------------------------------------------------------------------------
|
|
713
|
+
// scanner.py + models.py
|
|
714
|
+
// ---------------------------------------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
const CATEGORY_CONFIG = {
|
|
717
|
+
documentation: ['Architectural Documentation', 0.20],
|
|
718
|
+
constraints: ['Mechanical Constraints', 0.25],
|
|
719
|
+
testing: ['Testing & Stability', 0.25],
|
|
720
|
+
review: ['Review & Drift Prevention', 0.15],
|
|
721
|
+
ai_safeguards: ['AI-Specific Safeguards', 0.15],
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const GRADES = [['A', 85], ['B', 70], ['C', 55], ['D', 40]];
|
|
725
|
+
|
|
726
|
+
const GRADE_DESCRIPTIONS = {
|
|
727
|
+
A: 'Strong harness. AI-generated code has robust mechanical safeguards.',
|
|
728
|
+
B: 'Good foundation. Some gaps in enforcement or feedback loops.',
|
|
729
|
+
C: 'Basic practices present but insufficient for safe AI scaling.',
|
|
730
|
+
D: 'Significant gaps. AI code likely accumulating undetected debt.',
|
|
731
|
+
F: 'No meaningful harness. AI output is essentially unaudited.',
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
export function computeGrade(score) {
|
|
735
|
+
return GRADES.find(([, t]) => score >= t)?.[0] ?? 'F';
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* `assess_repo` — every check over `dir`. Scores stay unrounded here, summed in upstream's
|
|
740
|
+
* order (checks, then categories, from 0) so the floating-point totals are the same doubles;
|
|
741
|
+
* rounding is the renderers' job, as it is the reporters' upstream.
|
|
742
|
+
*/
|
|
743
|
+
export function assessRepo(dir) {
|
|
744
|
+
const ctx = new RepoContext(dir);
|
|
745
|
+
const categories = Object.entries(ALL_CHECKS).map(([id, checks]) => {
|
|
746
|
+
const [name, weight] = CATEGORY_CONFIG[id];
|
|
747
|
+
const results = checks.map((c) => c.run(ctx));
|
|
748
|
+
const score = results.reduce((a, c) => a + c.score, 0);
|
|
749
|
+
const maxScore = results.reduce((a, c) => a + c.maxPoints, 0);
|
|
750
|
+
const percentage = maxScore === 0 ? 0 : (score / maxScore) * 100;
|
|
751
|
+
return { id, name, weight, score, maxScore, percentage, checks: results };
|
|
752
|
+
});
|
|
753
|
+
const overallScore = categories.reduce((a, c) => a + c.percentage * c.weight, 0);
|
|
754
|
+
const grade = computeGrade(overallScore);
|
|
755
|
+
return {
|
|
756
|
+
repoPath: ctx.path,
|
|
757
|
+
repoName: path.basename(ctx.path),
|
|
758
|
+
languages: ctx.languages,
|
|
759
|
+
overallScore,
|
|
760
|
+
grade,
|
|
761
|
+
gradeDescription: GRADE_DESCRIPTIONS[grade],
|
|
762
|
+
totalChecks: categories.reduce((a, c) => a + c.checks.length, 0),
|
|
763
|
+
passedChecks: categories.reduce((a, c) => a + c.checks.filter((k) => k.passed).length, 0),
|
|
764
|
+
categories,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Python's `round(x, 1)`: the nearest double's decimal value decides, and an exact tie goes to
|
|
770
|
+
* the even digit. `toFixed` already rounds the exact binary value, which settles every case
|
|
771
|
+
* but the exact tie (x.x5 representable exactly, e.g. 0.25), where it rounds away from zero.
|
|
772
|
+
*/
|
|
773
|
+
export function round1(x) {
|
|
774
|
+
// `toFixed(30)` spells the double's exact decimal expansion, so an exact tie shows as one.
|
|
775
|
+
if (/\.\d50*$/.test(Math.abs(x).toFixed(30))) {
|
|
776
|
+
const down = Math.trunc(Math.abs(x) * 10);
|
|
777
|
+
return (Math.sign(x) * (down % 2 === 0 ? down : down + 1)) / 10;
|
|
778
|
+
}
|
|
779
|
+
return Number(x.toFixed(1));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** The JSON payload: upstream's reporter shape, keys renamed to this module's camelCase. */
|
|
783
|
+
export function renderJson(a) {
|
|
784
|
+
const r = (x) => round1(x);
|
|
785
|
+
return `${JSON.stringify({
|
|
786
|
+
...a,
|
|
787
|
+
overallScore: r(a.overallScore),
|
|
788
|
+
categories: a.categories.map((c) => ({
|
|
789
|
+
...c, score: r(c.score), maxScore: r(c.maxScore), percentage: r(c.percentage),
|
|
790
|
+
checks: c.checks.map((k) => ({ ...k, score: r(k.score), maxPoints: r(k.maxPoints) })),
|
|
791
|
+
})),
|
|
792
|
+
}, null, 2)}\n`;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** The text report: grade, the per-pillar table, then every check that is not at full marks. */
|
|
796
|
+
export function renderText(a) {
|
|
797
|
+
const out = [
|
|
798
|
+
`AI Harness Scorecard — ${a.repoName}`,
|
|
799
|
+
`Grade ${a.grade} — ${round1(a.overallScore)}/100 (${a.passedChecks}/${a.totalChecks} checks passing)`,
|
|
800
|
+
a.gradeDescription,
|
|
801
|
+
'',
|
|
802
|
+
];
|
|
803
|
+
const w = Math.max(...a.categories.map((c) => c.name.length));
|
|
804
|
+
for (const c of a.categories) {
|
|
805
|
+
out.push(` ${c.name.padEnd(w)} ${String(round1(c.percentage)).padStart(5)}% `
|
|
806
|
+
+ `(weight ${Math.round(c.weight * 100)}%)`);
|
|
807
|
+
}
|
|
808
|
+
const gaps = a.categories.flatMap((c) => c.checks.filter((k) => k.score < k.maxPoints));
|
|
809
|
+
if (gaps.length) {
|
|
810
|
+
out.push('', 'To improve:');
|
|
811
|
+
for (const k of gaps) {
|
|
812
|
+
out.push(` ${k.passed ? '~' : 'x'} ${k.name} (${round1(k.score)}/${round1(k.maxPoints)}) — ${k.evidence}`);
|
|
813
|
+
if (k.remediation) out.push(` fix: ${k.remediation}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
out.push('', 'Scores the repo (CI, tests, docs), not the harness files. Upstream: '
|
|
817
|
+
+ 'markmishaev76/ai-harness-scorecard (MIT).');
|
|
818
|
+
return `${out.join('\n')}\n`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The verb: a reading, never a gate — exit 0 whatever the grade. */
|
|
822
|
+
export function cmdScorecard(args) {
|
|
823
|
+
const dir = path.resolve(args.path ?? process.cwd());
|
|
824
|
+
if (!isDir(dir)) {
|
|
825
|
+
printErr(`[scorecard] not a directory: ${dir}\n`);
|
|
826
|
+
return 1;
|
|
827
|
+
}
|
|
828
|
+
const a = assessRepo(dir);
|
|
829
|
+
if (args.svg) {
|
|
830
|
+
// The README card (`scorecard-svg.mjs`). Written, not printed: an SVG on a terminal helps
|
|
831
|
+
// nobody, and the path is where doctor's freshness check reads it back.
|
|
832
|
+
writeText(path.resolve(args.svg), renderSvg(a));
|
|
833
|
+
printOut(`[scorecard] ${a.grade} ${round1(a.overallScore)}/100 → ${args.svg}\n`
|
|
834
|
+
+ `[scorecard] README badge: ${badgeUrl(a)}\n`);
|
|
835
|
+
return 0;
|
|
836
|
+
}
|
|
837
|
+
printOut(args.json ? renderJson(a) : renderText(a));
|
|
838
|
+
return 0;
|
|
839
|
+
}
|