@noctcore/harness 0.1.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/README.md +44 -0
- package/dist/cli.cjs +506 -0
- package/dist/cli.d.cts +31 -0
- package/dist/cli.d.ts +31 -0
- package/dist/cli.js +470 -0
- package/dist/index.cjs +64 -0
- package/dist/index.d.cts +136 -0
- package/dist/index.d.ts +136 -0
- package/dist/index.js +34 -0
- package/dist/run-B6_KI2bV.d.cts +93 -0
- package/dist/run-B6_KI2bV.d.ts +93 -0
- package/package.json +52 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
5
|
+
import { readFileSync as readFileSync2, realpathSync } from "fs";
|
|
6
|
+
import path3 from "path";
|
|
7
|
+
import process from "process";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
|
|
10
|
+
// src/lint-meta/ctx.ts
|
|
11
|
+
import { spawnSync } from "child_process";
|
|
12
|
+
import { existsSync, globSync, readFileSync } from "fs";
|
|
13
|
+
import path from "path";
|
|
14
|
+
function toPosixRel(rel) {
|
|
15
|
+
return rel.replace(/\\/g, "/");
|
|
16
|
+
}
|
|
17
|
+
function normalizeText(text) {
|
|
18
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
19
|
+
}
|
|
20
|
+
var EXEC_MAX_BUFFER = 16 * 1024 * 1024;
|
|
21
|
+
function createNodeCtx(root) {
|
|
22
|
+
return {
|
|
23
|
+
root,
|
|
24
|
+
read(rel) {
|
|
25
|
+
const abs = path.join(root, toPosixRel(rel));
|
|
26
|
+
if (!existsSync(abs)) return null;
|
|
27
|
+
return normalizeText(readFileSync(abs, "utf8"));
|
|
28
|
+
},
|
|
29
|
+
exists(rel) {
|
|
30
|
+
return existsSync(path.join(root, toPosixRel(rel)));
|
|
31
|
+
},
|
|
32
|
+
glob(pattern) {
|
|
33
|
+
return Array.from(globSync(pattern, { cwd: root })).map(toPosixRel);
|
|
34
|
+
},
|
|
35
|
+
exec(cmd) {
|
|
36
|
+
const res = spawnSync(cmd, {
|
|
37
|
+
cwd: root,
|
|
38
|
+
shell: true,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
41
|
+
maxBuffer: EXEC_MAX_BUFFER
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
code: typeof res.status === "number" ? res.status : 1,
|
|
45
|
+
stdout: res.stdout ?? "",
|
|
46
|
+
stderr: res.stderr ?? ""
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/lint-meta/registry.ts
|
|
53
|
+
import { pathToFileURL } from "url";
|
|
54
|
+
var DEFAULT_REGISTRY_RELATIVE_PATH = ".nightcore/lint-meta/registry.js";
|
|
55
|
+
var defaultImporter = (absPath) => import(pathToFileURL(absPath).href);
|
|
56
|
+
function isMetaRule(v) {
|
|
57
|
+
if (typeof v !== "object" || v === null) return false;
|
|
58
|
+
const r = v;
|
|
59
|
+
return typeof r.id === "string" && typeof r.run === "function";
|
|
60
|
+
}
|
|
61
|
+
function extractRules(mod) {
|
|
62
|
+
if (typeof mod !== "object" || mod === null) return null;
|
|
63
|
+
const m = mod;
|
|
64
|
+
const def = m.default;
|
|
65
|
+
const candidates = [
|
|
66
|
+
m.META_RULES,
|
|
67
|
+
def?.META_RULES,
|
|
68
|
+
m.default
|
|
69
|
+
];
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
if (Array.isArray(candidate) && candidate.every(isMetaRule)) return candidate;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
async function loadRegistry(absRegistryPath, importer = defaultImporter) {
|
|
76
|
+
let mod;
|
|
77
|
+
try {
|
|
78
|
+
mod = await importer(absRegistryPath);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { rules: [], error: err instanceof Error ? err.message : String(err) };
|
|
81
|
+
}
|
|
82
|
+
const rules = extractRules(mod);
|
|
83
|
+
if (rules === null) {
|
|
84
|
+
return {
|
|
85
|
+
rules: [],
|
|
86
|
+
error: "the registry must export `META_RULES` (a named export, or the default) as an array of { id, run } rule objects"
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { rules };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/lint-meta/run.ts
|
|
93
|
+
function runMetaRules(rules, ctx, onRule) {
|
|
94
|
+
const outcomes = [];
|
|
95
|
+
for (const rule of rules) {
|
|
96
|
+
onRule?.(rule);
|
|
97
|
+
const ciCritical = rule.ciCritical === true;
|
|
98
|
+
try {
|
|
99
|
+
outcomes.push({ id: rule.id, ciCritical, violations: rule.run(ctx), threw: null });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
outcomes.push({
|
|
102
|
+
id: rule.id,
|
|
103
|
+
ciCritical,
|
|
104
|
+
violations: [],
|
|
105
|
+
threw: err instanceof Error ? err.message : String(err)
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return outcomes;
|
|
110
|
+
}
|
|
111
|
+
function reportMetaOutcomes(outcomes) {
|
|
112
|
+
let criticalCount = 0;
|
|
113
|
+
let totalViolations = 0;
|
|
114
|
+
const lines = [];
|
|
115
|
+
for (const outcome of outcomes) {
|
|
116
|
+
if (outcome.threw !== null) {
|
|
117
|
+
lines.push(`[ERROR] ${outcome.id}: rule threw \u2014 ${outcome.threw}`);
|
|
118
|
+
criticalCount += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
for (const v of outcome.violations) {
|
|
122
|
+
totalViolations += 1;
|
|
123
|
+
const tag = outcome.ciCritical ? "ERROR" : "info";
|
|
124
|
+
lines.push(`[${tag}] ${v.rule} (${v.file}): ${v.message}`);
|
|
125
|
+
if (outcome.ciCritical) criticalCount += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { criticalCount, totalViolations, lines };
|
|
129
|
+
}
|
|
130
|
+
function exitCodeFor(report) {
|
|
131
|
+
return report.criticalCount > 0 ? 1 : 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/manifest.ts
|
|
135
|
+
import path2 from "path";
|
|
136
|
+
var MANIFEST_RELATIVE_PATH = path2.join(".nightcore", "harness.json");
|
|
137
|
+
var DEFAULT_CHECK_TIMEOUT_MS = 3e5;
|
|
138
|
+
var SUPPORTED_SCHEMA_MAJOR = 1;
|
|
139
|
+
function manifestPath(dir) {
|
|
140
|
+
return path2.join(dir, MANIFEST_RELATIVE_PATH);
|
|
141
|
+
}
|
|
142
|
+
function resolveSchema(raw) {
|
|
143
|
+
if (raw === void 0 || raw === null) return { tooNew: false, found: SUPPORTED_SCHEMA_MAJOR };
|
|
144
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
145
|
+
if (!Number.isFinite(n)) return { tooNew: true, found: Number.NaN };
|
|
146
|
+
const major = Math.floor(n);
|
|
147
|
+
return { tooNew: major > SUPPORTED_SCHEMA_MAJOR, found: major };
|
|
148
|
+
}
|
|
149
|
+
function planCheck(entry) {
|
|
150
|
+
if (typeof entry !== "object" || entry === null) return null;
|
|
151
|
+
const cfg = entry;
|
|
152
|
+
if (typeof cfg.name !== "string" || cfg.name.trim() === "") return null;
|
|
153
|
+
if (cfg.enabled === false) return null;
|
|
154
|
+
const kind = typeof cfg.kind === "string" ? cfg.kind : "";
|
|
155
|
+
if (kind === "shell") return null;
|
|
156
|
+
const command = typeof cfg.command === "string" ? cfg.command.trim() : "";
|
|
157
|
+
if (command === "") return null;
|
|
158
|
+
const tokens = command.split(/\s+/).filter((t) => t.length > 0);
|
|
159
|
+
const program = tokens[0];
|
|
160
|
+
if (program === void 0) return null;
|
|
161
|
+
const args = tokens.slice(1);
|
|
162
|
+
const declared = cfg.timeoutMs;
|
|
163
|
+
const timeoutMs = typeof declared === "number" && Number.isFinite(declared) && declared > 0 ? Math.floor(declared) : DEFAULT_CHECK_TIMEOUT_MS;
|
|
164
|
+
return { name: cfg.name, kind, command, program, args, timeoutMs };
|
|
165
|
+
}
|
|
166
|
+
function loadChecks(dir, read) {
|
|
167
|
+
const raw = read(manifestPath(dir));
|
|
168
|
+
if (raw === null) return { kind: "no-config" };
|
|
169
|
+
let value;
|
|
170
|
+
try {
|
|
171
|
+
value = JSON.parse(raw);
|
|
172
|
+
} catch {
|
|
173
|
+
return { kind: "no-config" };
|
|
174
|
+
}
|
|
175
|
+
if (typeof value !== "object" || value === null) return { kind: "no-config" };
|
|
176
|
+
const root = value;
|
|
177
|
+
const schema = resolveSchema(root.schemaVersion);
|
|
178
|
+
if (schema.tooNew) return { kind: "schema-too-new", found: schema.found };
|
|
179
|
+
const entries = root.checks;
|
|
180
|
+
if (!Array.isArray(entries)) return { kind: "no-config" };
|
|
181
|
+
const checks = [];
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
const planned = planCheck(entry);
|
|
184
|
+
if (planned !== null) checks.push(planned);
|
|
185
|
+
}
|
|
186
|
+
return { kind: "ready", checks };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/run.ts
|
|
190
|
+
var TAIL_LIMIT = 4e3;
|
|
191
|
+
function tailOutput(stdout, stderr) {
|
|
192
|
+
let combined = stdout;
|
|
193
|
+
if (stderr.length > 0) combined += `
|
|
194
|
+
${stderr}`;
|
|
195
|
+
if (combined.length > TAIL_LIMIT) {
|
|
196
|
+
return `\u2026${combined.slice(combined.length - TAIL_LIMIT)}`;
|
|
197
|
+
}
|
|
198
|
+
return combined;
|
|
199
|
+
}
|
|
200
|
+
function timeoutMessage(timeoutMs) {
|
|
201
|
+
return `timed out after ${timeoutMs}ms (the check was killed; it may hang or need a higher timeoutMs)`;
|
|
202
|
+
}
|
|
203
|
+
function interpret(res, timeoutMs) {
|
|
204
|
+
const timedOut = res.error?.code === "ETIMEDOUT" || res.signal != null;
|
|
205
|
+
if (timedOut) {
|
|
206
|
+
return { status: "failed", output: timeoutMessage(timeoutMs) };
|
|
207
|
+
}
|
|
208
|
+
if (res.error) {
|
|
209
|
+
const detail = res.error.message ?? res.error.code ?? "unknown error";
|
|
210
|
+
return { status: "failed", output: `failed to launch: ${detail}` };
|
|
211
|
+
}
|
|
212
|
+
if (res.status === 0) {
|
|
213
|
+
return { status: "passed", exitCode: 0 };
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
status: "failed",
|
|
217
|
+
exitCode: res.status ?? void 0,
|
|
218
|
+
output: tailOutput(res.stdout, res.stderr)
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function emptyPass() {
|
|
222
|
+
return { passed: true, checks: [] };
|
|
223
|
+
}
|
|
224
|
+
function runChecks(planned, runDir, spawn, onCommand) {
|
|
225
|
+
if (planned.length === 0) return emptyPass();
|
|
226
|
+
const checks = [];
|
|
227
|
+
let failedCheck;
|
|
228
|
+
for (const check of planned) {
|
|
229
|
+
onCommand?.(check);
|
|
230
|
+
const start = Date.now();
|
|
231
|
+
const res = spawn(check.program, check.args, {
|
|
232
|
+
cwd: runDir,
|
|
233
|
+
timeoutMs: check.timeoutMs
|
|
234
|
+
});
|
|
235
|
+
const durationMs = Date.now() - start;
|
|
236
|
+
const outcome = interpret(res, check.timeoutMs);
|
|
237
|
+
if (outcome.status === "failed" && failedCheck === void 0) {
|
|
238
|
+
failedCheck = check.name;
|
|
239
|
+
}
|
|
240
|
+
checks.push({
|
|
241
|
+
name: check.name,
|
|
242
|
+
kind: check.kind,
|
|
243
|
+
command: check.command,
|
|
244
|
+
status: outcome.status,
|
|
245
|
+
exitCode: outcome.exitCode,
|
|
246
|
+
output: outcome.output,
|
|
247
|
+
durationMs
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const result = { passed: failedCheck === void 0, checks };
|
|
251
|
+
if (failedCheck !== void 0) result.failedCheck = failedCheck;
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
function fixInstruction(result) {
|
|
255
|
+
const failed = result.checks.filter((c) => c.status === "failed");
|
|
256
|
+
if (failed.length === 0) {
|
|
257
|
+
return "The Structure-Lock check failed. Fix the project's harness checks before this work can be verified or merged.";
|
|
258
|
+
}
|
|
259
|
+
let out = `The Structure-Lock check failed: ${failed.length} project harness check${failed.length === 1 ? "" : "s"} did not pass. They MUST all pass before this work can be verified or merged. Re-run each one locally and fix every violation it reports:`;
|
|
260
|
+
for (const c of failed) {
|
|
261
|
+
out += `
|
|
262
|
+
|
|
263
|
+
--- \`${c.name}\` ---
|
|
264
|
+
Command: ${c.command}
|
|
265
|
+
|
|
266
|
+
Output:
|
|
267
|
+
${c.output ?? "(no output captured)"}`;
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/cli.ts
|
|
273
|
+
var HELP = `@noctcore/harness \u2014 portable Structure-Lock runner
|
|
274
|
+
|
|
275
|
+
Usage:
|
|
276
|
+
harness [check] [options] Run the checks declared in .nightcore/harness.json
|
|
277
|
+
harness lint-meta [options] Run the portable lint-meta rules from the enumerated registry
|
|
278
|
+
|
|
279
|
+
Options:
|
|
280
|
+
--dir <path> Target directory to operate in (default: current directory)
|
|
281
|
+
--registry <path> lint-meta only: the rule registry to load
|
|
282
|
+
(default: ${DEFAULT_REGISTRY_RELATIVE_PATH}, relative to --dir)
|
|
283
|
+
--json check only: emit the machine-readable result to stdout instead of a summary
|
|
284
|
+
--version Print the runner version and exit
|
|
285
|
+
--help Print this help and exit
|
|
286
|
+
|
|
287
|
+
Exit codes:
|
|
288
|
+
0 every check/rule passed (or nothing is configured to enforce)
|
|
289
|
+
1 a check failed, a rule reported a critical violation or threw, or the manifest requires a newer runner
|
|
290
|
+
2 a usage error`;
|
|
291
|
+
function parseArgs(argv, cwd) {
|
|
292
|
+
let command = "check";
|
|
293
|
+
let sawCommand = false;
|
|
294
|
+
let dir = cwd;
|
|
295
|
+
let registry;
|
|
296
|
+
let json = false;
|
|
297
|
+
let help = false;
|
|
298
|
+
let version = false;
|
|
299
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
300
|
+
const arg = argv[i];
|
|
301
|
+
if (arg === void 0) continue;
|
|
302
|
+
if (arg === "--json") {
|
|
303
|
+
json = true;
|
|
304
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
305
|
+
help = true;
|
|
306
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
307
|
+
version = true;
|
|
308
|
+
} else if (arg === "--dir") {
|
|
309
|
+
const next = argv[i + 1];
|
|
310
|
+
if (next !== void 0) {
|
|
311
|
+
dir = next;
|
|
312
|
+
i += 1;
|
|
313
|
+
}
|
|
314
|
+
} else if (arg.startsWith("--dir=")) {
|
|
315
|
+
dir = arg.slice("--dir=".length);
|
|
316
|
+
} else if (arg === "--registry") {
|
|
317
|
+
const next = argv[i + 1];
|
|
318
|
+
if (next !== void 0) {
|
|
319
|
+
registry = next;
|
|
320
|
+
i += 1;
|
|
321
|
+
}
|
|
322
|
+
} else if (arg.startsWith("--registry=")) {
|
|
323
|
+
registry = arg.slice("--registry=".length);
|
|
324
|
+
} else if (!arg.startsWith("-") && !sawCommand) {
|
|
325
|
+
command = arg;
|
|
326
|
+
sawCommand = true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { command, dir: path3.resolve(cwd, dir), registry, json, help, version };
|
|
330
|
+
}
|
|
331
|
+
function readVersion() {
|
|
332
|
+
try {
|
|
333
|
+
const raw = readFileSync2(new URL("../package.json", import.meta.url), "utf8");
|
|
334
|
+
const pkg = JSON.parse(raw);
|
|
335
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
336
|
+
} catch {
|
|
337
|
+
return "0.0.0";
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function nodeIO() {
|
|
341
|
+
return {
|
|
342
|
+
cwd: process.cwd(),
|
|
343
|
+
read(absolutePath) {
|
|
344
|
+
try {
|
|
345
|
+
return readFileSync2(absolutePath, "utf8");
|
|
346
|
+
} catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
spawn(program, args, options) {
|
|
351
|
+
const res = spawnSync2(program, args, {
|
|
352
|
+
cwd: options.cwd,
|
|
353
|
+
timeout: options.timeoutMs,
|
|
354
|
+
killSignal: "SIGKILL",
|
|
355
|
+
encoding: "utf8",
|
|
356
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
357
|
+
maxBuffer: 16 * 1024 * 1024
|
|
358
|
+
});
|
|
359
|
+
const err = res.error;
|
|
360
|
+
return {
|
|
361
|
+
status: res.status,
|
|
362
|
+
signal: res.signal,
|
|
363
|
+
stdout: res.stdout ?? "",
|
|
364
|
+
stderr: res.stderr ?? "",
|
|
365
|
+
error: err ? { code: err.code, message: err.message } : void 0
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
stdout: (line) => process.stdout.write(`${line}
|
|
369
|
+
`),
|
|
370
|
+
stderr: (line) => process.stderr.write(`${line}
|
|
371
|
+
`),
|
|
372
|
+
importModule: defaultImporter
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function runCheck(parsed, io) {
|
|
376
|
+
const outcome = loadChecks(parsed.dir, io.read);
|
|
377
|
+
if (outcome.kind === "no-config") {
|
|
378
|
+
if (parsed.json) {
|
|
379
|
+
io.stdout(JSON.stringify(emptyPass()));
|
|
380
|
+
} else {
|
|
381
|
+
io.stdout("No structure lock configured \u2014 nothing to enforce.");
|
|
382
|
+
}
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
if (outcome.kind === "schema-too-new") {
|
|
386
|
+
const label = Number.isFinite(outcome.found) ? String(outcome.found) : "an unrecognized value";
|
|
387
|
+
io.stderr(
|
|
388
|
+
`This .nightcore/harness.json declares schemaVersion ${label}, which this runner does not understand. This bundle was authored by a newer Nightcore \u2014 upgrade @noctcore/harness.`
|
|
389
|
+
);
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
const result = runChecks(outcome.checks, parsed.dir, io.spawn, (check) => {
|
|
393
|
+
const line = `\u2192 ${check.name}: ${check.command}`;
|
|
394
|
+
if (parsed.json) io.stderr(line);
|
|
395
|
+
else io.stdout(line);
|
|
396
|
+
});
|
|
397
|
+
if (parsed.json) {
|
|
398
|
+
io.stdout(JSON.stringify(result));
|
|
399
|
+
return result.passed ? 0 : 1;
|
|
400
|
+
}
|
|
401
|
+
for (const check of result.checks) {
|
|
402
|
+
const mark = check.status === "passed" ? "\u2713" : "\u2717";
|
|
403
|
+
const exit = check.exitCode != null ? ` (exit ${check.exitCode})` : "";
|
|
404
|
+
io.stdout(`${mark} ${check.name}${exit}`);
|
|
405
|
+
}
|
|
406
|
+
if (result.passed) {
|
|
407
|
+
const n = result.checks.length;
|
|
408
|
+
io.stdout(`
|
|
409
|
+
Structure lock passed (${n} check${n === 1 ? "" : "s"}).`);
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
io.stderr(`
|
|
413
|
+
${fixInstruction(result)}`);
|
|
414
|
+
return 1;
|
|
415
|
+
}
|
|
416
|
+
async function runLintMeta(parsed, io) {
|
|
417
|
+
const registryPath = parsed.registry ? path3.resolve(parsed.dir, parsed.registry) : path3.join(parsed.dir, DEFAULT_REGISTRY_RELATIVE_PATH);
|
|
418
|
+
if (io.read(registryPath) === null) {
|
|
419
|
+
io.stdout(
|
|
420
|
+
`No lint-meta registry at ${registryPath} \u2014 nothing to enforce. (Point --registry at your rule registry, or commit one at ${DEFAULT_REGISTRY_RELATIVE_PATH}.)`
|
|
421
|
+
);
|
|
422
|
+
return 0;
|
|
423
|
+
}
|
|
424
|
+
const loaded = await loadRegistry(registryPath, io.importModule ?? defaultImporter);
|
|
425
|
+
if (loaded.error !== void 0) {
|
|
426
|
+
io.stderr(`Failed to load the lint-meta registry at ${registryPath}: ${loaded.error}`);
|
|
427
|
+
return 1;
|
|
428
|
+
}
|
|
429
|
+
const n = loaded.rules.length;
|
|
430
|
+
io.stdout(`lint-meta: running ${n} rule${n === 1 ? "" : "s"} from ${registryPath}`);
|
|
431
|
+
const ctx = createNodeCtx(parsed.dir);
|
|
432
|
+
const outcomes = runMetaRules(loaded.rules, ctx, (rule) => io.stdout(`\u2192 ${rule.id}`));
|
|
433
|
+
const report = reportMetaOutcomes(outcomes);
|
|
434
|
+
for (const line of report.lines) io.stderr(line);
|
|
435
|
+
if (report.lines.length === 0) io.stdout("lint-meta: no violations");
|
|
436
|
+
return exitCodeFor(report);
|
|
437
|
+
}
|
|
438
|
+
function runCli(argv, io = nodeIO()) {
|
|
439
|
+
const parsed = parseArgs(argv, io.cwd);
|
|
440
|
+
if (parsed.help) {
|
|
441
|
+
io.stdout(HELP);
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
if (parsed.version) {
|
|
445
|
+
io.stdout(readVersion());
|
|
446
|
+
return 0;
|
|
447
|
+
}
|
|
448
|
+
if (parsed.command === "check") return runCheck(parsed, io);
|
|
449
|
+
if (parsed.command === "lint-meta") return runLintMeta(parsed, io);
|
|
450
|
+
io.stderr(`Unknown command: ${parsed.command}. Run \`harness --help\`.`);
|
|
451
|
+
return 2;
|
|
452
|
+
}
|
|
453
|
+
function isMainModule() {
|
|
454
|
+
try {
|
|
455
|
+
const entry = process.argv[1];
|
|
456
|
+
if (entry === void 0) return false;
|
|
457
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
458
|
+
} catch {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (isMainModule()) {
|
|
463
|
+
void Promise.resolve(runCli(process.argv.slice(2))).then((code) => {
|
|
464
|
+
process.exit(code);
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
export {
|
|
468
|
+
nodeIO,
|
|
469
|
+
runCli
|
|
470
|
+
};
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
DEFAULT_BASELINE_DIR: () => DEFAULT_BASELINE_DIR,
|
|
24
|
+
isGrandfathered: () => isGrandfathered,
|
|
25
|
+
loadBaseline: () => loadBaseline,
|
|
26
|
+
serializeBaseline: () => serializeBaseline
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
|
|
30
|
+
// src/lint-meta/baseline.ts
|
|
31
|
+
var DEFAULT_BASELINE_DIR = ".nightcore/lint-meta/baselines";
|
|
32
|
+
function loadBaseline(ctx, ruleId, baselineDir = DEFAULT_BASELINE_DIR) {
|
|
33
|
+
const raw = ctx.read(`${baselineDir}/${ruleId}.json`);
|
|
34
|
+
if (raw === null) return {};
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return isNumberMap(parsed) ? parsed : {};
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isGrandfathered(baseline, key, current) {
|
|
43
|
+
const frozen = baseline[key];
|
|
44
|
+
return frozen !== void 0 && current <= frozen;
|
|
45
|
+
}
|
|
46
|
+
function serializeBaseline(map) {
|
|
47
|
+
const sorted = {};
|
|
48
|
+
for (const key of Object.keys(map).sort()) {
|
|
49
|
+
const value = map[key];
|
|
50
|
+
if (value !== void 0) sorted[key] = value;
|
|
51
|
+
}
|
|
52
|
+
return `${JSON.stringify(sorted, null, 2)}
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
function isNumberMap(v) {
|
|
56
|
+
return typeof v === "object" && v !== null && Object.values(v).every((n) => typeof n === "number");
|
|
57
|
+
}
|
|
58
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
59
|
+
0 && (module.exports = {
|
|
60
|
+
DEFAULT_BASELINE_DIR,
|
|
61
|
+
isGrandfathered,
|
|
62
|
+
loadBaseline,
|
|
63
|
+
serializeBaseline
|
|
64
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export { C as CheckStatus, M as ManifestOutcome, P as PlannedCheck, a as StructureLockCheck, b as StructureLockResult } from './run-B6_KI2bV.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The PORTABLE lint-meta contract — the types a target repo's generated
|
|
5
|
+
* `lint-meta-rule` artifacts implement and import from `@noctcore/harness`.
|
|
6
|
+
*
|
|
7
|
+
* This is an owned COPY of the Nightcore-internal `tools/lint-meta/types.ts`
|
|
8
|
+
* (the engine that enforces what ESLint cannot reach: cross-file contracts,
|
|
9
|
+
* non-JS files, config parity). It is reproduced here, not imported, so the
|
|
10
|
+
* published package carries the whole contract with ZERO runtime dependency on
|
|
11
|
+
* the monorepo — a generated rule in a stranger's repo does
|
|
12
|
+
* `import type { IMetaRule } from '@noctcore/harness'` and nothing else.
|
|
13
|
+
*
|
|
14
|
+
* Rules are pure functions of an {@link IMetaCtx} and return {@link IViolation}s;
|
|
15
|
+
* the runner exits non-zero when any `ciCritical` rule reports one (or throws).
|
|
16
|
+
*/
|
|
17
|
+
interface IMetaCtx {
|
|
18
|
+
/** Absolute repo root the rule reads relative to. */
|
|
19
|
+
readonly root: string;
|
|
20
|
+
/** Read a repo-relative file (LF-normalized), or `null` if it does not exist. */
|
|
21
|
+
read(rel: string): string | null;
|
|
22
|
+
/** Whether a repo-relative path exists. */
|
|
23
|
+
exists(rel: string): boolean;
|
|
24
|
+
/** Glob repo-relative paths (cwd = {@link root}). */
|
|
25
|
+
glob(pattern: string): string[];
|
|
26
|
+
/** Run a shell command at {@link root}; never throws. */
|
|
27
|
+
exec(cmd: string): {
|
|
28
|
+
code: number;
|
|
29
|
+
stdout: string;
|
|
30
|
+
stderr: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
interface IViolation {
|
|
34
|
+
file: string;
|
|
35
|
+
rule: string;
|
|
36
|
+
message: string;
|
|
37
|
+
/**
|
|
38
|
+
* 1-indexed source location, when the rule can pinpoint one. Optional and
|
|
39
|
+
* additive: the text reporter ignores it (it surfaces `file`/`rule`/`message`
|
|
40
|
+
* only), so a rule may omit it.
|
|
41
|
+
*/
|
|
42
|
+
line?: number;
|
|
43
|
+
column?: number;
|
|
44
|
+
}
|
|
45
|
+
interface IMetaRule {
|
|
46
|
+
id: string;
|
|
47
|
+
category: 'config' | 'source-text' | 'supply-chain' | 'ci' | 'testing';
|
|
48
|
+
description: string;
|
|
49
|
+
/** When true, a violation fails CI (the runner exits non-zero). */
|
|
50
|
+
ciCritical?: boolean;
|
|
51
|
+
run(ctx: IMetaCtx): IViolation[];
|
|
52
|
+
/**
|
|
53
|
+
* Ratcheting rules implement this to snapshot the CURRENT offenders as a frozen
|
|
54
|
+
* baseline (a flat `metric-key → number` map). A rule's `run` then grandfathers
|
|
55
|
+
* any offender still within its recorded value (see `baseline.ts`). Omit for
|
|
56
|
+
* strict rules.
|
|
57
|
+
*/
|
|
58
|
+
baseline?(ctx: IMetaCtx): Record<string, number>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The generic lint-meta ratchet — a faithful port of `tools/lint-meta/baseline.ts`.
|
|
63
|
+
*
|
|
64
|
+
* A baseline is a committed `<baselineDir>/<rule-id>.json` — a flat `key → number`
|
|
65
|
+
* map freezing today's offenders at their current metric. The ratchet is one-way:
|
|
66
|
+
* an offender that is recorded AND has not grown past its frozen value is
|
|
67
|
+
* grandfathered (suppressed); a NEW offender, or a recorded one that GREW, is a
|
|
68
|
+
* live violation. As each offender is fixed, its entry is deleted (or lowered),
|
|
69
|
+
* never raised — the frozen debt only shrinks.
|
|
70
|
+
*
|
|
71
|
+
* The `key` is opaque to this module: a rule with more than one metric family
|
|
72
|
+
* namespaces its keys (`size:<file>`, `manifest:<file>`) so one flat map serves both.
|
|
73
|
+
*
|
|
74
|
+
* The only portability change from the internal engine is the baseline HOME: the
|
|
75
|
+
* Nightcore engine hardcodes `tools/lint-meta/baselines/`, whereas a portable rule
|
|
76
|
+
* ships its baselines under {@link DEFAULT_BASELINE_DIR} (overridable per call).
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/** Where a portable rule's committed baselines live, relative to the repo root. */
|
|
80
|
+
declare const DEFAULT_BASELINE_DIR = ".nightcore/lint-meta/baselines";
|
|
81
|
+
/** Load a rule's committed baseline, or `{}` when none exists yet. */
|
|
82
|
+
declare function loadBaseline(ctx: IMetaCtx, ruleId: string, baselineDir?: string): Record<string, number>;
|
|
83
|
+
/**
|
|
84
|
+
* Whether `current` for `key` is grandfathered by `baseline`: recorded AND not
|
|
85
|
+
* grown past the frozen value. A key absent from the baseline is never
|
|
86
|
+
* grandfathered (a new offender always fails); a recorded key whose current value
|
|
87
|
+
* is `<=` its record passes (the ratchet permits staying same-or-shrinking).
|
|
88
|
+
*/
|
|
89
|
+
declare function isGrandfathered(baseline: Record<string, number>, key: string, current: number): boolean;
|
|
90
|
+
/** Serialize a baseline map with sorted keys for a stable, diff-friendly file. */
|
|
91
|
+
declare function serializeBaseline(map: Record<string, number>): string;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Public type surface of `@noctcore/harness`. Exposes the
|
|
95
|
+
* `.nightcore/harness.json` manifest shape, the `StructureLockResult`-shaped
|
|
96
|
+
* verdict the runner emits (camelCase, wire-compatible with the Rust
|
|
97
|
+
* `StructureLockResult` / `StructureLockCheck`), AND the portable lint-meta
|
|
98
|
+
* contract (`IMetaRule` / `IMetaCtx` / `IViolation`) a target repo's generated
|
|
99
|
+
* `lint-meta-rule` artifacts implement.
|
|
100
|
+
*
|
|
101
|
+
* This barrel exists so generated artifacts and downstream tooling can
|
|
102
|
+
* `import type` from the published package.
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/** One check as declared in `.nightcore/harness.json`. */
|
|
106
|
+
interface HarnessManifestCheck {
|
|
107
|
+
/** The logical check name (e.g. `folder-per-component`). */
|
|
108
|
+
name: string;
|
|
109
|
+
/** The harness kind (e.g. `lint-plugin`, `ast-grep`). Free-form on the wire. */
|
|
110
|
+
kind: string;
|
|
111
|
+
/** The exact command line to run (e.g. `npx eslint .`). */
|
|
112
|
+
command?: string;
|
|
113
|
+
/** Optional config path for the underlying tool (informational). */
|
|
114
|
+
configPath?: string;
|
|
115
|
+
/** Per-check wall-clock timeout in milliseconds (`> 0`, else the default). */
|
|
116
|
+
timeoutMs?: number;
|
|
117
|
+
/** Whether the check participates in the gate. Defaults to `true`. */
|
|
118
|
+
enabled?: boolean;
|
|
119
|
+
}
|
|
120
|
+
/** The `.nightcore/harness.json` manifest the runner reads. */
|
|
121
|
+
interface HarnessManifest {
|
|
122
|
+
/**
|
|
123
|
+
* The manifest MAJOR schema version. Absent ⇒ treated as `1`. A higher MAJOR
|
|
124
|
+
* than the runner supports reds the build (upgrade the runner).
|
|
125
|
+
*/
|
|
126
|
+
schemaVersion?: number;
|
|
127
|
+
/** The checks the runner enforces. Absent/empty ⇒ nothing to enforce. */
|
|
128
|
+
checks?: HarnessManifestCheck[];
|
|
129
|
+
/**
|
|
130
|
+
* The Nightcore agent-runtime policy block. Carried for Nightcore-driven
|
|
131
|
+
* consumers; the CI runner does not interpret it.
|
|
132
|
+
*/
|
|
133
|
+
policy?: Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { DEFAULT_BASELINE_DIR, type HarnessManifest, type HarnessManifestCheck, type IMetaCtx, type IMetaRule, type IViolation, isGrandfathered, loadBaseline, serializeBaseline };
|