mandrel 2.38.0 → 2.39.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/.agents/README.md +45 -8
- package/.agents/docs/agentrc-reference.json +1 -4
- package/.agents/docs/configuration.md +2 -2
- package/.agents/schemas/agentrc.schema.json +6 -7
- package/.agents/scripts/generate-skills-index.js +158 -75
- package/.agents/scripts/lib/changed-files.js +100 -9
- package/.agents/scripts/lib/config-settings-schema.js +25 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
- package/.agents/scripts/lib/skills/skills-index.js +168 -0
- package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
- package/.agents/scripts/quality-preview.js +50 -9
- package/.agents/scripts/validate-skills.js +53 -66
- package/.agents/workflows/qa-run.md +13 -5
- package/docs/CHANGELOG.md +12 -0
- package/package.json +1 -1
|
@@ -26,11 +26,25 @@
|
|
|
26
26
|
* harness invocation — by exact name or by raw-URL origin match against each
|
|
27
27
|
* environment's `baseUrl` — and throws loudly (naming the known environments)
|
|
28
28
|
* on an unknown name or unmatched URL.
|
|
29
|
+
*
|
|
30
|
+
* It also **resolves the selected environment's `signInSeam`** (Story #5135).
|
|
31
|
+
* A `{ skill }` seam naming an id that resolves to no readable `SKILL.md`
|
|
32
|
+
* under either skills root used to fail silently: the contract validated, the
|
|
33
|
+
* seam was returned unread, and the dangling pointer only surfaced much later
|
|
34
|
+
* when a sweep reached its sign-in step — after the harness had already
|
|
35
|
+
* driven a browser. Resolution now happens here, at config-resolution time,
|
|
36
|
+
* so the failure lands where the operator can fix `.agentrc.json`. A seam
|
|
37
|
+
* that is absent entirely is a legitimate, declarable state (the workflows
|
|
38
|
+
* drive the unauthenticated surface and record the gap), not an error.
|
|
29
39
|
*/
|
|
30
40
|
|
|
31
41
|
import Ajv from 'ajv';
|
|
32
|
-
|
|
33
42
|
import { QA_SCHEMA } from '../config-settings-schema.js';
|
|
43
|
+
import { PROJECT_ROOT } from '../project-root.js';
|
|
44
|
+
import {
|
|
45
|
+
resolveSkillFile,
|
|
46
|
+
SKILL_SEARCH_ROOTS,
|
|
47
|
+
} from '../skills/walk-skill-files.js';
|
|
34
48
|
|
|
35
49
|
/**
|
|
36
50
|
* The harness-required fields. The AJV `QA_SCHEMA` keeps these optional so
|
|
@@ -242,6 +256,40 @@ function toOrigin(value) {
|
|
|
242
256
|
}
|
|
243
257
|
}
|
|
244
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Normalize and verify one environment's `signInSeam`.
|
|
261
|
+
*
|
|
262
|
+
* An absent seam normalizes to `null` — a declarable state, not an error.
|
|
263
|
+
* A `{ skill }` seam is resolved against both skills roots and throws when
|
|
264
|
+
* it resolves under neither, so a dangling pointer is caught here rather
|
|
265
|
+
* than mid-sweep. The resolved `SKILL.md` path is attached as
|
|
266
|
+
* `skillPath` so the harness reads the file the check actually found.
|
|
267
|
+
*
|
|
268
|
+
* @param {object | undefined} seam
|
|
269
|
+
* @param {string} envName Environment name, for the error message.
|
|
270
|
+
* @param {{ repoRoot?: string }} options
|
|
271
|
+
* @returns {object | null}
|
|
272
|
+
*/
|
|
273
|
+
function resolveSignInSeam(seam, envName, options) {
|
|
274
|
+
if (seam == null) return null;
|
|
275
|
+
if (typeof seam.skill !== 'string') return seam;
|
|
276
|
+
|
|
277
|
+
const repoRoot = options.repoRoot ?? PROJECT_ROOT;
|
|
278
|
+
const found = resolveSkillFile(repoRoot, seam.skill);
|
|
279
|
+
if (found === null) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`qa: environment \`${envName}\` declares signInSeam.skill ` +
|
|
282
|
+
`\`${seam.skill}\`, which resolves to no readable SKILL.md. ` +
|
|
283
|
+
`Searched ${SKILL_SEARCH_ROOTS.map((r) => `\`${r}/<skill>/SKILL.md\``).join(' and ')}. ` +
|
|
284
|
+
'Author the skill under the consumer-writable `.agents/local/skills/` ' +
|
|
285
|
+
'zone (it is never pruned by `mandrel sync` and never flagged as ' +
|
|
286
|
+
'payload drift), correct the id, or omit `signInSeam` entirely if ' +
|
|
287
|
+
'this target genuinely has no sign-in seam.',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return { ...seam, skillPath: found.path };
|
|
291
|
+
}
|
|
292
|
+
|
|
245
293
|
/**
|
|
246
294
|
* Resolve a single QA environment for one harness invocation.
|
|
247
295
|
*
|
|
@@ -262,13 +310,17 @@ function toOrigin(value) {
|
|
|
262
310
|
* Fails **loudly**: an unknown name or an unmatched URL throws an error that
|
|
263
311
|
* names the known environments so the operator can correct the invocation.
|
|
264
312
|
*
|
|
265
|
-
* @param {{ environments: Record<string, { baseUrl: string, signInSeam
|
|
313
|
+
* @param {{ environments: Record<string, { baseUrl: string, signInSeam?: object, allowWrites?: boolean }>, defaultEnvironment: string }} contract
|
|
266
314
|
* A contract returned by `resolveQaContract`.
|
|
267
315
|
* @param {string} [target] Environment name or raw URL. Omit for the default.
|
|
268
|
-
* @
|
|
269
|
-
*
|
|
316
|
+
* @param {{ repoRoot?: string }} [options] `repoRoot` roots skill-seam
|
|
317
|
+
* resolution; defaults to the project root. Injected by tests.
|
|
318
|
+
* @returns {{ name: string, baseUrl: string, signInSeam: object | null, allowWrites: boolean }}
|
|
319
|
+
* `signInSeam` is `null` when the environment declares none.
|
|
320
|
+
* @throws {Error} on an unknown name, an unmatched URL, or a `{ skill }` seam
|
|
321
|
+
* that resolves under no skills root.
|
|
270
322
|
*/
|
|
271
|
-
export function resolveQaEnvironment(contract, target) {
|
|
323
|
+
export function resolveQaEnvironment(contract, target, options = {}) {
|
|
272
324
|
const environments = contract?.environments;
|
|
273
325
|
if (
|
|
274
326
|
environments == null ||
|
|
@@ -320,7 +372,7 @@ export function resolveQaEnvironment(contract, target) {
|
|
|
320
372
|
return {
|
|
321
373
|
name: resolvedName,
|
|
322
374
|
baseUrl: env.baseUrl,
|
|
323
|
-
signInSeam: env.signInSeam,
|
|
375
|
+
signInSeam: resolveSignInSeam(env.signInSeam, resolvedName, options),
|
|
324
376
|
allowWrites,
|
|
325
377
|
};
|
|
326
378
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// .agents/scripts/lib/skills/skills-index.js
|
|
2
|
+
//
|
|
3
|
+
// Shared I/O for the two skills manifests (Story #5135).
|
|
4
|
+
//
|
|
5
|
+
// Each skills root carries its own `skills.index.json`: the package payload's
|
|
6
|
+
// at `.agents/skills/`, and the consumer-writable zone's at
|
|
7
|
+
// `.agents/local/skills/`. The shipped one is a committed payload file that
|
|
8
|
+
// `mandrel doctor` / `mandrel sync-agents` compare byte-for-byte against the
|
|
9
|
+
// installed package, so the two manifests must never be merged — but they are
|
|
10
|
+
// read, compared and reported identically, and both CLIs need that logic.
|
|
11
|
+
// Before this module `generate-skills-index.js` and `validate-skills.js`
|
|
12
|
+
// each carried their own near-identical reader.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** Manifest filename, shared by both roots. */
|
|
18
|
+
export const INDEX_FILENAME = 'skills.index.json';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Absolute path of the manifest for one skills root.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} repoRoot
|
|
24
|
+
* @param {readonly string[]} rootSegments From `walk-skill-files.js`.
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function indexPathFor(repoRoot, rootSegments) {
|
|
28
|
+
return path.join(repoRoot, ...rootSegments, INDEX_FILENAME);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read a manifest from disk. Distinguishes "missing" from "unparseable" via
|
|
33
|
+
* the `reason` channel so callers can report which drift they hit rather than
|
|
34
|
+
* collapsing both into "not fresh".
|
|
35
|
+
*
|
|
36
|
+
* @param {string} indexPath
|
|
37
|
+
* @returns {{ manifest: object | null, reason: string | null }}
|
|
38
|
+
*/
|
|
39
|
+
export function readManifest(indexPath) {
|
|
40
|
+
if (!fs.existsSync(indexPath)) {
|
|
41
|
+
return { manifest: null, reason: 'missing' };
|
|
42
|
+
}
|
|
43
|
+
let src;
|
|
44
|
+
try {
|
|
45
|
+
src = fs.readFileSync(indexPath, 'utf8');
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return { manifest: null, reason: `read-error: ${err.message}` };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return { manifest: JSON.parse(src), reason: null };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { manifest: null, reason: `parse-error: ${err.message}` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read a manifest and project its entry paths into a Set, the shape the
|
|
58
|
+
* validator's membership check consumes.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} indexPath
|
|
61
|
+
* @returns {{ exists: boolean, paths: Set<string> | null, manifest: object | null, indexPath: string, parseError?: string }}
|
|
62
|
+
*/
|
|
63
|
+
export function readIndexPaths(indexPath) {
|
|
64
|
+
const { manifest, reason } = readManifest(indexPath);
|
|
65
|
+
if (reason === 'missing') {
|
|
66
|
+
return { exists: false, paths: null, manifest: null, indexPath };
|
|
67
|
+
}
|
|
68
|
+
if (manifest === null) {
|
|
69
|
+
return {
|
|
70
|
+
exists: true,
|
|
71
|
+
paths: null,
|
|
72
|
+
manifest: null,
|
|
73
|
+
indexPath,
|
|
74
|
+
parseError: reason,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const paths = new Set(
|
|
78
|
+
Array.isArray(manifest.skills)
|
|
79
|
+
? manifest.skills.map((s) => s.path).filter((p) => typeof p === 'string')
|
|
80
|
+
: [],
|
|
81
|
+
);
|
|
82
|
+
return { exists: true, paths, manifest, indexPath };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compare two manifests ignoring `generatedAt` — the one volatile field, which
|
|
87
|
+
* changes on every write and is not content. Returns null when they match, or
|
|
88
|
+
* a diff-style message naming the entry counts.
|
|
89
|
+
*
|
|
90
|
+
* @param {object | null} diskManifest
|
|
91
|
+
* @param {object} freshManifest
|
|
92
|
+
* @param {string} label Manifest name for the message.
|
|
93
|
+
* @returns {string | null}
|
|
94
|
+
*/
|
|
95
|
+
export function diffManifests(diskManifest, freshManifest, label) {
|
|
96
|
+
if (diskManifest === null) {
|
|
97
|
+
return `${label}: on-disk manifest is missing or unreadable`;
|
|
98
|
+
}
|
|
99
|
+
const a = { ...diskManifest };
|
|
100
|
+
const b = { ...freshManifest };
|
|
101
|
+
a.generatedAt = undefined;
|
|
102
|
+
b.generatedAt = undefined;
|
|
103
|
+
if (JSON.stringify(a) === JSON.stringify(b)) return null;
|
|
104
|
+
const count = (m) => (Array.isArray(m.skills) ? m.skills.length : 'n/a');
|
|
105
|
+
return [
|
|
106
|
+
`${label} drift detected:`,
|
|
107
|
+
` on-disk entries: ${count(diskManifest)}`,
|
|
108
|
+
` generated entries: ${count(freshManifest)}`,
|
|
109
|
+
" run 'node .agents/scripts/generate-skills-index.js' to refresh",
|
|
110
|
+
].join('\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Render a manifest's schema violations as field-named findings. The compiled
|
|
115
|
+
* AJV validator is passed in so this module stays free of the schema-loading
|
|
116
|
+
* side effects the validator CLI owns.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} manifest
|
|
119
|
+
* @param {string} indexRelPath Repo-relative manifest path, for the message.
|
|
120
|
+
* @param {(m: object) => boolean} validateManifest Compiled AJV validator.
|
|
121
|
+
* @returns {string[]}
|
|
122
|
+
*/
|
|
123
|
+
function validateManifestSchema(manifest, indexRelPath, validateManifest) {
|
|
124
|
+
const findings = [];
|
|
125
|
+
if (validateManifest(manifest)) return findings;
|
|
126
|
+
for (const err of validateManifest.errors ?? []) {
|
|
127
|
+
const where = err.instancePath || '(root)';
|
|
128
|
+
findings.push(
|
|
129
|
+
`${indexRelPath}: manifest-schema: schema violation at ${where}: ${err.message}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return findings;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Audit one root's manifest: present, parseable, and schema-valid. Shared by
|
|
137
|
+
* both skills roots so a consumer-authored index is held to the same bar as
|
|
138
|
+
* the shipped one.
|
|
139
|
+
*
|
|
140
|
+
* @param {{ exists: boolean, paths: Set<string> | null, manifest: object | null, parseError?: string }} indexInfo
|
|
141
|
+
* @param {string} indexRelPath
|
|
142
|
+
* @param {(m: object) => boolean} validateManifest
|
|
143
|
+
* @param {{ required: boolean }} options
|
|
144
|
+
* @returns {string[]}
|
|
145
|
+
*/
|
|
146
|
+
export function auditIndex(
|
|
147
|
+
indexInfo,
|
|
148
|
+
indexRelPath,
|
|
149
|
+
validateManifest,
|
|
150
|
+
{ required },
|
|
151
|
+
) {
|
|
152
|
+
if (!indexInfo.exists) {
|
|
153
|
+
return required
|
|
154
|
+
? [
|
|
155
|
+
`index missing: ${indexRelPath} not found — run 'node .agents/scripts/generate-skills-index.js'`,
|
|
156
|
+
]
|
|
157
|
+
: [];
|
|
158
|
+
}
|
|
159
|
+
if (indexInfo.paths === null) {
|
|
160
|
+
return [`index unparseable: ${indexRelPath} — ${indexInfo.parseError}`];
|
|
161
|
+
}
|
|
162
|
+
if (indexInfo.manifest === null) return [];
|
|
163
|
+
return validateManifestSchema(
|
|
164
|
+
indexInfo.manifest,
|
|
165
|
+
indexRelPath,
|
|
166
|
+
validateManifest,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -1,12 +1,56 @@
|
|
|
1
1
|
// .agents/scripts/lib/skills/walk-skill-files.js
|
|
2
2
|
//
|
|
3
|
-
// Shared traversal for SKILL.md files
|
|
3
|
+
// Shared traversal for SKILL.md files across the two skills roots:
|
|
4
|
+
// the package payload (`.agents/skills/{core,stack}/`) and the
|
|
5
|
+
// consumer-writable local zone (`.agents/local/skills/{core,stack}/`).
|
|
4
6
|
// Used by validate-skills.js and generate-skills-index.js so both CLIs
|
|
5
7
|
// enumerate the same paths in the same deterministic order.
|
|
8
|
+
//
|
|
9
|
+
// The two roots stay **separately enumerable** on purpose (Story #5135).
|
|
10
|
+
// `.agents/skills/skills.index.json` is a committed payload file that
|
|
11
|
+
// `mandrel doctor` / `mandrel sync-agents` compare byte-for-byte against
|
|
12
|
+
// the installed package; folding a consumer's local skills into it would
|
|
13
|
+
// make every consumer's regenerated index read as payload drift and cause
|
|
14
|
+
// those commands to refuse. The local zone therefore carries its own
|
|
15
|
+
// index artifact, and the two roots are unified only at *lookup* time, by
|
|
16
|
+
// `resolveSkillFile` — never for the shipped manifest.
|
|
6
17
|
|
|
7
18
|
import fs from 'node:fs';
|
|
8
19
|
import path from 'node:path';
|
|
9
20
|
|
|
21
|
+
/** Tier directories a skills root is enumerated under. */
|
|
22
|
+
const TIERS = Object.freeze(['core', 'stack']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Path segments (from the repo root) of the package-payload skills root.
|
|
26
|
+
* Materialized by `mandrel sync`; every file under it is payload.
|
|
27
|
+
*/
|
|
28
|
+
export const PAYLOAD_SKILLS_SEGMENTS = Object.freeze(['.agents', 'skills']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Path segments (from the repo root) of the consumer-writable skills root.
|
|
32
|
+
* It sits inside the `.agents/local/` zone (Story #3498), which sync never
|
|
33
|
+
* copies into and never prunes, and which the agents-drift check cannot
|
|
34
|
+
* flag because that check only walks files present in the package payload.
|
|
35
|
+
*/
|
|
36
|
+
export const LOCAL_SKILLS_SEGMENTS = Object.freeze([
|
|
37
|
+
'.agents',
|
|
38
|
+
'local',
|
|
39
|
+
'skills',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A skill id is the tier-relative path naming a skill — e.g.
|
|
44
|
+
* `core/scope-triage` or `stack/qa/playwright`. It is the value that
|
|
45
|
+
* appears in `skills.index.json` minus the root prefix, and the value a
|
|
46
|
+
* `qa.environments.*.signInSeam.skill` seam carries.
|
|
47
|
+
*
|
|
48
|
+
* The pattern is deliberately strict: ids resolve to filesystem paths, so
|
|
49
|
+
* anything that could escape a root (`..`, absolute paths, backslashes) or
|
|
50
|
+
* smuggle a shell metacharacter is rejected rather than normalized.
|
|
51
|
+
*/
|
|
52
|
+
const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/;
|
|
53
|
+
|
|
10
54
|
/**
|
|
11
55
|
* Recursively enumerate `SKILL.md` paths under a directory.
|
|
12
56
|
*
|
|
@@ -38,19 +82,99 @@ function walkSkillFiles(rootDir) {
|
|
|
38
82
|
}
|
|
39
83
|
|
|
40
84
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
85
|
+
* Sort absolute paths by their POSIX repo-relative form so output order is
|
|
86
|
+
* deterministic across platforms.
|
|
43
87
|
*
|
|
88
|
+
* @param {string[]} files
|
|
44
89
|
* @param {string} repoRoot
|
|
45
|
-
* @returns {string[]}
|
|
90
|
+
* @returns {string[]}
|
|
46
91
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const coreFiles = walkSkillFiles(path.join(skillsRoot, 'core'));
|
|
50
|
-
const stackFiles = walkSkillFiles(path.join(skillsRoot, 'stack'));
|
|
51
|
-
return [...coreFiles, ...stackFiles].sort((a, b) => {
|
|
92
|
+
function sortByRepoRelative(files, repoRoot) {
|
|
93
|
+
return [...files].sort((a, b) => {
|
|
52
94
|
const ra = path.relative(repoRoot, a).split(path.sep).join('/');
|
|
53
95
|
const rb = path.relative(repoRoot, b).split(path.sep).join('/');
|
|
54
96
|
return ra < rb ? -1 : ra > rb ? 1 : 0;
|
|
55
97
|
});
|
|
56
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Enumerate the `SKILL.md` files under one skills root, sorted by POSIX
|
|
102
|
+
* repo-relative path.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} repoRoot
|
|
105
|
+
* @param {readonly string[]} rootSegments One of the exported segment lists.
|
|
106
|
+
* @returns {string[]} absolute paths
|
|
107
|
+
*/
|
|
108
|
+
function collectUnderRoot(repoRoot, rootSegments) {
|
|
109
|
+
const skillsRoot = path.join(repoRoot, ...rootSegments);
|
|
110
|
+
const files = TIERS.flatMap((tier) =>
|
|
111
|
+
walkSkillFiles(path.join(skillsRoot, tier)),
|
|
112
|
+
);
|
|
113
|
+
return sortByRepoRelative(files, repoRoot);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the list of payload SKILL.md files under
|
|
118
|
+
* `<repoRoot>/.agents/skills/{core,stack}/`.
|
|
119
|
+
*
|
|
120
|
+
* This is the set the **shipped** `skills.index.json` is generated from —
|
|
121
|
+
* it must never include local-zone skills (see the module header).
|
|
122
|
+
*
|
|
123
|
+
* @param {string} repoRoot
|
|
124
|
+
* @returns {string[]} absolute paths
|
|
125
|
+
*/
|
|
126
|
+
export function collectSkillFiles(repoRoot) {
|
|
127
|
+
return collectUnderRoot(repoRoot, PAYLOAD_SKILLS_SEGMENTS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build the list of consumer-authored SKILL.md files under
|
|
132
|
+
* `<repoRoot>/.agents/local/skills/{core,stack}/`. Empty when the
|
|
133
|
+
* consumer has authored none — the common case, and the case in this
|
|
134
|
+
* repository itself.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} repoRoot
|
|
137
|
+
* @returns {string[]} absolute paths
|
|
138
|
+
*/
|
|
139
|
+
export function collectLocalSkillFiles(repoRoot) {
|
|
140
|
+
return collectUnderRoot(repoRoot, LOCAL_SKILLS_SEGMENTS);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve a skill id to a readable `SKILL.md`, searching the payload root
|
|
145
|
+
* first and the local zone second (payload-wins, matching
|
|
146
|
+
* {@link collectAllSkillFiles}).
|
|
147
|
+
*
|
|
148
|
+
* Returns `null` rather than throwing so callers own the error message —
|
|
149
|
+
* a config resolver wants to name the offending config key, a workflow
|
|
150
|
+
* wants to name the seam.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} repoRoot
|
|
153
|
+
* @param {string} skillId Tier-relative id, e.g. `stack/qa/acme-sso`.
|
|
154
|
+
* @returns {{ path: string, root: string } | null} absolute `SKILL.md`
|
|
155
|
+
* path and the POSIX repo-relative root it resolved under.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveSkillFile(repoRoot, skillId) {
|
|
158
|
+
if (typeof skillId !== 'string' || !SKILL_ID_RE.test(skillId)) return null;
|
|
159
|
+
for (const segments of [PAYLOAD_SKILLS_SEGMENTS, LOCAL_SKILLS_SEGMENTS]) {
|
|
160
|
+
const candidate = path.join(repoRoot, ...segments, skillId, 'SKILL.md');
|
|
161
|
+
try {
|
|
162
|
+
if (fs.statSync(candidate).isFile()) {
|
|
163
|
+
return { path: candidate, root: segments.join('/') };
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Unreadable or absent — try the next root.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The POSIX repo-relative skills roots, in search order. Exported so error
|
|
174
|
+
* messages can name exactly what was searched rather than restating the
|
|
175
|
+
* paths as literals.
|
|
176
|
+
*/
|
|
177
|
+
export const SKILL_SEARCH_ROOTS = Object.freeze([
|
|
178
|
+
PAYLOAD_SKILLS_SEGMENTS.join('/'),
|
|
179
|
+
LOCAL_SKILLS_SEGMENTS.join('/'),
|
|
180
|
+
]);
|
|
@@ -12,9 +12,18 @@
|
|
|
12
12
|
* 2. `npm run quality:watch` — chokidar wrapper re-emits on save.
|
|
13
13
|
* 3. `.husky/pre-commit` — block the commit on threshold violations.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* pre-commit
|
|
15
|
+
* The pre-commit hook passes `--staged` and nothing else — the index is
|
|
16
|
+
* already the exact delta the operator is about to commit, and
|
|
17
|
+
* `tests/pre-commit-hook.test.js` pins that `--changed-since` stays off it.
|
|
18
|
+
* (This docblock previously claimed the hook passed `--changed-since HEAD`,
|
|
19
|
+
* describing a wiring the hook has not used for some time; the stale prose
|
|
20
|
+
* sent at least one bug report at the wrong flag — Story #5131.)
|
|
21
|
+
*
|
|
22
|
+
* `--staged` is merge-aware: while a merge is in progress the index is read
|
|
23
|
+
* against `MERGE_HEAD` rather than `HEAD`, so a base-sync merge commit is
|
|
24
|
+
* scored for the merging branch's own work and its conflict resolutions, not
|
|
25
|
+
* for everything the base branch landed. See `resolveMergeHead` in
|
|
26
|
+
* `lib/changed-files.js`.
|
|
18
27
|
*
|
|
19
28
|
* The CLI exits 0 when both envelopes report zero violations and the script
|
|
20
29
|
* could not surface a regression. Any violation in either envelope, or any
|
|
@@ -29,6 +38,7 @@ import {
|
|
|
29
38
|
runCrapPreview,
|
|
30
39
|
runMaintainabilityPreview,
|
|
31
40
|
} from './lib/baselines/preview-gates.js';
|
|
41
|
+
import { resolveMergeHead } from './lib/changed-files.js';
|
|
32
42
|
import { respondToHelp } from './lib/cli-usage.js';
|
|
33
43
|
import { getQuality, resolveConfig } from './lib/config-resolver.js';
|
|
34
44
|
import { resolveCyclomaticPolicy } from './lib/cyclomatic-ceiling.js';
|
|
@@ -39,7 +49,10 @@ const USAGE = {
|
|
|
39
49
|
summary:
|
|
40
50
|
'Preview the per-file maintainability and CRAP deltas for the change set, and exit non-zero on any threshold violation.',
|
|
41
51
|
flags: [
|
|
42
|
-
[
|
|
52
|
+
[
|
|
53
|
+
'--staged',
|
|
54
|
+
'Score the git index only (the pre-commit-hook scope). During a merge the index is read against MERGE_HEAD.',
|
|
55
|
+
],
|
|
43
56
|
[
|
|
44
57
|
'--changed-since <ref>',
|
|
45
58
|
'Score the diff against <ref> (default: HEAD). Last occurrence wins.',
|
|
@@ -388,6 +401,35 @@ function runGateSafely(runner, args, label, stderr) {
|
|
|
388
401
|
});
|
|
389
402
|
}
|
|
390
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Render the scope header line.
|
|
406
|
+
*
|
|
407
|
+
* Story #5131 — when `--staged` runs during a merge the scope is re-based to
|
|
408
|
+
* `MERGE_HEAD`, and the header says so. Without that line the operator sees a
|
|
409
|
+
* table whose row count does not match `git diff --cached` with no way to tell
|
|
410
|
+
* the narrowing was deliberate.
|
|
411
|
+
*
|
|
412
|
+
* The merge state is resolved here rather than read back off a gate envelope's
|
|
413
|
+
* `summary.diffRef`: that field means "the ref this scope was resolved
|
|
414
|
+
* against" for every scope kind, so anything that populates it — a future
|
|
415
|
+
* scope mode, a test stub — would render a merge banner over a repo that is
|
|
416
|
+
* not merging. Resolving it after the `!staged` early return also keeps the
|
|
417
|
+
* probe off the `--changed-since` path, which has no use for it.
|
|
418
|
+
*
|
|
419
|
+
* @param {{ staged: boolean, ref: string|null, cwd: string }} args
|
|
420
|
+
* @returns {string}
|
|
421
|
+
*/
|
|
422
|
+
function stagedScopeLine({ staged, ref, cwd }) {
|
|
423
|
+
if (!staged) return `scope=diff ref=${ref}\n\n`;
|
|
424
|
+
const mergeHead = resolveMergeHead({ cwd });
|
|
425
|
+
if (!mergeHead) return 'scope=staged (git diff --cached)\n\n';
|
|
426
|
+
return (
|
|
427
|
+
`scope=staged (git diff --cached ${mergeHead.slice(0, 12)}) — merge in ` +
|
|
428
|
+
"progress: scored against MERGE_HEAD, not HEAD, so the base branch's " +
|
|
429
|
+
'incoming files are excluded\n\n'
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
391
433
|
/**
|
|
392
434
|
* Write the run's report — the `--json` envelope, or the human-readable
|
|
393
435
|
* table plus any gate diagnostics and the non-zero-exit summary.
|
|
@@ -401,6 +443,7 @@ function runGateSafely(runner, args, label, stderr) {
|
|
|
401
443
|
* json: boolean,
|
|
402
444
|
* staged: boolean,
|
|
403
445
|
* ref: string|null,
|
|
446
|
+
* cwd: string,
|
|
404
447
|
* miResult: {exitCode: number, envelope: object|null},
|
|
405
448
|
* crapResult: {exitCode: number, envelope: object|null},
|
|
406
449
|
* merged: ReturnType<typeof mergeEnvelopes>,
|
|
@@ -413,6 +456,7 @@ function emitReport({
|
|
|
413
456
|
json,
|
|
414
457
|
staged,
|
|
415
458
|
ref,
|
|
459
|
+
cwd,
|
|
416
460
|
miResult,
|
|
417
461
|
crapResult,
|
|
418
462
|
merged,
|
|
@@ -438,11 +482,7 @@ function emitReport({
|
|
|
438
482
|
return;
|
|
439
483
|
}
|
|
440
484
|
stdout.write('\n--- quality:preview ---\n');
|
|
441
|
-
stdout.write(
|
|
442
|
-
staged
|
|
443
|
-
? 'scope=staged (git diff --cached)\n\n'
|
|
444
|
-
: `scope=diff ref=${ref}\n\n`,
|
|
445
|
-
);
|
|
485
|
+
stdout.write(stagedScopeLine({ staged, ref, cwd }));
|
|
446
486
|
stdout.write(`${renderTable(merged)}\n`);
|
|
447
487
|
const diagnostics = renderDiagnostics([miResult, crapResult]);
|
|
448
488
|
if (diagnostics) stdout.write(`\n${diagnostics}\n`);
|
|
@@ -518,6 +558,7 @@ export async function runCli({
|
|
|
518
558
|
json,
|
|
519
559
|
staged,
|
|
520
560
|
ref,
|
|
561
|
+
cwd,
|
|
521
562
|
miResult,
|
|
522
563
|
crapResult,
|
|
523
564
|
merged,
|