@rungs/cli 0.3.1 → 0.4.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 +6 -6
- package/dist/cli.js +2184 -478
- package/dist/cli.js.map +4 -4
- package/modules/README.md +25 -3
- package/modules/adr/files/{{path}}/README.md +1 -1
- package/modules/adr/gates/adr.toml +1 -1
- package/modules/adr/module.toml +1 -1
- package/modules/audit/fragments/AGENTS.md +2 -2
- package/modules/audit/module.toml +1 -1
- package/modules/audit/skills/assess/SKILL.md +1 -1
- package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
- package/modules/backlog/files/docs/{{root}}/README.md +2 -2
- package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
- package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
- package/modules/backlog/fragments/AGENTS.md +2 -2
- package/modules/backlog/module.toml +1 -1
- package/modules/backlog/skills/work-item/SKILL.md +1 -1
- package/modules/ci/files/{{workflow_path}} +3 -3
- package/modules/ci/module.toml +1 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
- package/modules/concurrency/fragments/AGENTS.md +5 -4
- package/modules/concurrency/fragments/gitattributes +2 -2
- package/modules/concurrency/gates/concurrency.toml +3 -3
- package/modules/concurrency/module.toml +1 -1
- package/modules/doc-authority/files/{{registry_path}} +1 -1
- package/modules/doc-authority/module.toml +1 -1
- package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
- package/modules/findings/gates/findings.toml +5 -0
- package/modules/findings/module.toml +1 -1
- package/modules/findings/skills/record-finding/SKILL.md +1 -1
- package/modules/gates/files/.ai/gates.toml +1 -1
- package/modules/gates/fragments/AGENTS.md +6 -5
- package/modules/gates/module.toml +1 -1
- package/modules/instructions/files/.ai/rules/README.md +2 -2
- package/modules/instructions/files/.ai/rungs.mjs +52 -0
- package/modules/instructions/files/AGENTS.md +4 -2
- package/modules/instructions/files/CLAUDE.md +1 -1
- package/modules/instructions/fragments/AGENTS.md +2 -2
- package/modules/instructions/gates/core.toml +2 -2
- package/modules/instructions/module.toml +1 -1
- package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
- package/modules/release/gates/release.toml +169 -17
- package/modules/release/module.toml +9 -5
- package/modules/release/skills/cut-release/SKILL.md +43 -15
- package/modules/session/files/{{archive}}/README.md +1 -1
- package/modules/session/files/{{path}} +2 -2
- package/modules/session/module.toml +1 -1
- package/modules/specs/files/{{path}}/README.md +2 -2
- package/modules/specs/module.toml +1 -1
- package/modules/workflows/module.toml +1 -1
- package/modules/workflows/rules/planning-tiers.md +1 -1
- package/package.json +3 -2
- package/src/add.ts +204 -48
- package/src/backlog.ts +354 -48
- package/src/check.ts +54 -33
- package/src/cli.ts +196 -69
- package/src/concurrency.ts +628 -42
- package/src/detect.ts +11 -3
- package/src/emitted-path.ts +274 -0
- package/src/engine-table.ts +66 -0
- package/src/engines.ts +18 -29
- package/src/engines2.ts +403 -20
- package/src/engines3.ts +111 -20
- package/src/explain.ts +3 -7
- package/src/help.ts +43 -0
- package/src/lifecycle.ts +86 -27
- package/src/manifest.ts +41 -5
- package/src/render.ts +106 -21
- package/src/selftest.ts +87 -10
- package/src/storage-key.ts +20 -0
- package/src/substitute.ts +47 -5
- package/src/text.ts +11 -0
- package/src/types.ts +16 -3
- package/src/version-source.ts +144 -0
package/src/detect.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import type { DetectResult, Manifest } from './types.ts';
|
|
4
4
|
import { matchAny, walk } from './glob.ts';
|
|
5
|
-
import { contentHash, emittedFiles } from './add.ts';
|
|
5
|
+
import { contentHash, emittedFiles, preflightModuleEmissions } from './add.ts';
|
|
6
|
+
import { resolveEmittedPath } from './emitted-path.ts';
|
|
6
7
|
import type { Params } from './substitute.ts';
|
|
7
8
|
|
|
8
9
|
const SAMPLE = 3;
|
|
@@ -215,7 +216,9 @@ export interface InstalledModule {
|
|
|
215
216
|
*/
|
|
216
217
|
export function ownedState(mod: Manifest, repoRoot: string, installed: InstalledModule) {
|
|
217
218
|
const params = installed.params_all ?? {};
|
|
218
|
-
const
|
|
219
|
+
const skillsDir = installed.skillsDir ?? '.claude/skills';
|
|
220
|
+
preflightModuleEmissions([mod], repoRoot, params, skillsDir);
|
|
221
|
+
const emitted = emittedFiles(mod, params, skillsDir);
|
|
219
222
|
const kept = new Set(installed.kept?.files ?? []);
|
|
220
223
|
const out = {
|
|
221
224
|
version: installed.version,
|
|
@@ -232,7 +235,12 @@ export function ownedState(mod: Manifest, repoRoot: string, installed: Installed
|
|
|
232
235
|
out.kept.push(rel);
|
|
233
236
|
continue;
|
|
234
237
|
}
|
|
235
|
-
const
|
|
238
|
+
const resolved = resolveEmittedPath(repoRoot, mod.name, rel);
|
|
239
|
+
const full = resolved.absolute;
|
|
240
|
+
if (resolved.leafAlias) {
|
|
241
|
+
out.diverged.push(rel);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
236
244
|
if (!existsSync(full)) {
|
|
237
245
|
out.missing.push(rel);
|
|
238
246
|
continue;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { lstatSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep, win32 } from 'node:path';
|
|
3
|
+
import { canonicalCaselessSegmentEqual } from './storage-key.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A module path is repository-relative data, not a host-native path. Treating
|
|
7
|
+
* both separators as structural is what makes the same parameter safe when a
|
|
8
|
+
* record written on Windows is later upgraded on POSIX (or the reverse).
|
|
9
|
+
*/
|
|
10
|
+
export class UnsafeEmittedPathError extends Error {
|
|
11
|
+
readonly moduleName: string;
|
|
12
|
+
readonly target: string;
|
|
13
|
+
readonly reason: string;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
moduleName: string,
|
|
17
|
+
target: string,
|
|
18
|
+
reason: string,
|
|
19
|
+
) {
|
|
20
|
+
super(`module '${moduleName}' emitted unsafe target ${JSON.stringify(target)}: ${reason}`);
|
|
21
|
+
this.name = 'UnsafeEmittedPathError';
|
|
22
|
+
this.moduleName = moduleName;
|
|
23
|
+
this.target = target;
|
|
24
|
+
this.reason = reason;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ResolvedEmittedPath {
|
|
29
|
+
/** Portable, slash-separated form used in actions and install records. */
|
|
30
|
+
target: string;
|
|
31
|
+
/** Canonical absolute destination proven to be below the canonical repo root. */
|
|
32
|
+
absolute: string;
|
|
33
|
+
/** The final path entry itself is an alias; writes must not follow it. */
|
|
34
|
+
leafAlias: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EmittedPathCandidate {
|
|
38
|
+
moduleName: string;
|
|
39
|
+
target: string;
|
|
40
|
+
/** Managed block/shared-registry destinations may intentionally coincide. */
|
|
41
|
+
shared?: boolean;
|
|
42
|
+
/** This phase replaces or merges an existing file rather than keeping it. */
|
|
43
|
+
writeExisting?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const missingEntry = (error: unknown) =>
|
|
47
|
+
error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
|
48
|
+
|
|
49
|
+
function hasUnpairedUtf16Surrogate(value: string): boolean {
|
|
50
|
+
for (let i = 0; i < value.length; i++) {
|
|
51
|
+
const unit = value.charCodeAt(i);
|
|
52
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
53
|
+
const next = value.charCodeAt(i + 1);
|
|
54
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (unit >= 0xdc00 && unit <= 0xdfff) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function canonicalCaselessAncestor(ancestor: string, descendant: string): boolean {
|
|
64
|
+
const ancestorSegments = ancestor.split(sep);
|
|
65
|
+
const descendantSegments = descendant.split(sep);
|
|
66
|
+
return (
|
|
67
|
+
ancestorSegments.length < descendantSegments.length &&
|
|
68
|
+
ancestorSegments.every((segment, index) => canonicalCaselessSegmentEqual(segment, descendantSegments[index]))
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function canonicalCaselessPathEqual(left: string, right: string): boolean {
|
|
73
|
+
const leftSegments = left.split(sep);
|
|
74
|
+
const rightSegments = right.split(sep);
|
|
75
|
+
return (
|
|
76
|
+
leftSegments.length === rightSegments.length &&
|
|
77
|
+
leftSegments.every((segment, index) => canonicalCaselessSegmentEqual(segment, rightSegments[index]))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Realpath the deepest existing ancestor, then append the still-missing suffix.
|
|
83
|
+
* `resolve()` alone cannot see an in-repository symlink or junction that points
|
|
84
|
+
* out of the repository. A dangling alias fails closed because realpath cannot
|
|
85
|
+
* establish where a subsequent write would land.
|
|
86
|
+
*/
|
|
87
|
+
function canonicalWithMissing(path: string, moduleName: string, target: string): string {
|
|
88
|
+
let cursor = resolve(path);
|
|
89
|
+
const suffix: string[] = [];
|
|
90
|
+
|
|
91
|
+
for (;;) {
|
|
92
|
+
try {
|
|
93
|
+
lstatSync(cursor);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!missingEntry(error)) {
|
|
96
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'its existing ancestor cannot be inspected');
|
|
97
|
+
}
|
|
98
|
+
const parent = dirname(cursor);
|
|
99
|
+
if (parent === cursor) {
|
|
100
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'no canonical existing ancestor can be established');
|
|
101
|
+
}
|
|
102
|
+
suffix.unshift(basename(cursor));
|
|
103
|
+
cursor = parent;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let canonical: string;
|
|
108
|
+
try {
|
|
109
|
+
canonical = realpathSync.native(cursor);
|
|
110
|
+
} catch {
|
|
111
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'its existing ancestor cannot be resolved canonically');
|
|
112
|
+
}
|
|
113
|
+
if (suffix.length) {
|
|
114
|
+
try {
|
|
115
|
+
if (!statSync(canonical).isDirectory()) {
|
|
116
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'its deepest existing ancestor is not a directory');
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof UnsafeEmittedPathError) throw error;
|
|
120
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'its existing ancestor cannot be inspected');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return resolve(canonical, ...suffix);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve one module-emitted destination and prove it remains in `repoRoot`.
|
|
129
|
+
* Unsafe syntax is refused before host path resolution so `C:relative`, `\\rooted`
|
|
130
|
+
* and mixed-separator traversal cannot change meaning between operating systems.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveEmittedPath(repoRoot: string, moduleName: string, target: string): ResolvedEmittedPath {
|
|
133
|
+
if (!target || target.includes('\0')) {
|
|
134
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'a non-empty portable relative file path is required');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const portable = target.replace(/\\/g, '/');
|
|
138
|
+
if (portable.startsWith('/') || win32.isAbsolute(target) || /^[A-Za-z]:/.test(portable)) {
|
|
139
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'absolute, rooted, and drive-relative paths are not allowed');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const segments = portable.split('/');
|
|
143
|
+
if (segments.includes('..')) {
|
|
144
|
+
throw new UnsafeEmittedPathError(moduleName, target, "parent traversal ('..') is not allowed");
|
|
145
|
+
}
|
|
146
|
+
if (segments.some((segment) => segment === '' || segment === '.')) {
|
|
147
|
+
throw new UnsafeEmittedPathError(moduleName, target, "empty and current-directory ('.') path segments are not allowed");
|
|
148
|
+
}
|
|
149
|
+
if (segments.some(hasUnpairedUtf16Surrogate)) {
|
|
150
|
+
throw new UnsafeEmittedPathError(
|
|
151
|
+
moduleName,
|
|
152
|
+
target,
|
|
153
|
+
'unpaired UTF-16 surrogate code units are not allowed in path segments',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (segments.some((segment) => /[\u0000-\u001f<>:"|?*]/.test(segment) || /[ .]$/.test(segment))) {
|
|
157
|
+
throw new UnsafeEmittedPathError(
|
|
158
|
+
moduleName,
|
|
159
|
+
target,
|
|
160
|
+
'it contains a character or trailing suffix that is not a portable filename',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const windowsDevice = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
164
|
+
if (segments.some((segment) => windowsDevice.test(segment))) {
|
|
165
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'Windows device-name path segments are not allowed');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const canonicalRoot = canonicalWithMissing(repoRoot, moduleName, target);
|
|
169
|
+
const lexicalDestination = resolve(repoRoot, ...segments);
|
|
170
|
+
const canonicalDestination = canonicalWithMissing(lexicalDestination, moduleName, target);
|
|
171
|
+
const fromRoot = relative(canonicalRoot, canonicalDestination);
|
|
172
|
+
|
|
173
|
+
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
174
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'it resolves outside the canonical consumer repository');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let leafAlias = false;
|
|
178
|
+
try {
|
|
179
|
+
leafAlias = lstatSync(lexicalDestination).isSymbolicLink();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!missingEntry(error)) {
|
|
182
|
+
throw new UnsafeEmittedPathError(moduleName, target, 'its destination cannot be inspected');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { target: portable, absolute: canonicalDestination, leafAlias };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Resolve a complete operation and reject two exclusive names for one destination. */
|
|
190
|
+
export function preflightEmittedPaths(
|
|
191
|
+
repoRoot: string,
|
|
192
|
+
candidates: EmittedPathCandidate[],
|
|
193
|
+
): ResolvedEmittedPath[] {
|
|
194
|
+
const resolved = candidates.map((candidate) => {
|
|
195
|
+
const destination = resolveEmittedPath(repoRoot, candidate.moduleName, candidate.target);
|
|
196
|
+
if (candidate.writeExisting) {
|
|
197
|
+
if (destination.leafAlias) {
|
|
198
|
+
throw new UnsafeEmittedPathError(
|
|
199
|
+
candidate.moduleName,
|
|
200
|
+
candidate.target,
|
|
201
|
+
'the destination is a symlink or junction leaf and this operation will not write through it',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Every overwrite/merge sink ultimately uses writeFileSync. Prove an
|
|
206
|
+
// existing leaf is a regular file now, during the complete-operation
|
|
207
|
+
// preflight, so a later directory/FIFO/socket cannot fail after an
|
|
208
|
+
// earlier candidate has already been written. A hard-linked file is also
|
|
209
|
+
// refused: replacing it would mutate every other name for the same inode,
|
|
210
|
+
// including a name outside the consumer repository.
|
|
211
|
+
try {
|
|
212
|
+
const leaf = lstatSync(destination.absolute);
|
|
213
|
+
if (!leaf.isFile()) {
|
|
214
|
+
throw new UnsafeEmittedPathError(
|
|
215
|
+
candidate.moduleName,
|
|
216
|
+
candidate.target,
|
|
217
|
+
'the existing destination is not a regular file',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (leaf.nlink > 1) {
|
|
221
|
+
throw new UnsafeEmittedPathError(
|
|
222
|
+
candidate.moduleName,
|
|
223
|
+
candidate.target,
|
|
224
|
+
'the existing destination has multiple hard links and this operation will not overwrite it',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error instanceof UnsafeEmittedPathError) throw error;
|
|
229
|
+
if (!missingEntry(error)) {
|
|
230
|
+
throw new UnsafeEmittedPathError(
|
|
231
|
+
candidate.moduleName,
|
|
232
|
+
candidate.target,
|
|
233
|
+
'the destination cannot be inspected before writing',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return destination;
|
|
239
|
+
});
|
|
240
|
+
const seen: { candidate: EmittedPathCandidate; resolved: ResolvedEmittedPath }[] = [];
|
|
241
|
+
|
|
242
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
243
|
+
const candidate = candidates[i];
|
|
244
|
+
const destination = resolved[i];
|
|
245
|
+
// A module plan is portable: names that coexist only on a case-sensitive
|
|
246
|
+
// checkout are still one destination when that record reaches Windows or a
|
|
247
|
+
// default case-insensitive macOS volume. Use the same conservative storage
|
|
248
|
+
// relation as managed refs, including compatibility and full case forms.
|
|
249
|
+
const prior = seen.find((entry) => canonicalCaselessPathEqual(entry.resolved.absolute, destination.absolute));
|
|
250
|
+
if (!prior) {
|
|
251
|
+
const structural = seen.find((entry) =>
|
|
252
|
+
canonicalCaselessAncestor(entry.resolved.absolute, destination.absolute) ||
|
|
253
|
+
canonicalCaselessAncestor(destination.absolute, entry.resolved.absolute),
|
|
254
|
+
);
|
|
255
|
+
if (!structural) {
|
|
256
|
+
seen.push({ candidate, resolved: destination });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
throw new UnsafeEmittedPathError(
|
|
260
|
+
candidate.moduleName,
|
|
261
|
+
candidate.target,
|
|
262
|
+
`it has a file/descendant collision with module '${structural.candidate.moduleName}' target '${structural.candidate.target}' after canonical resolution`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (candidate.shared && prior.candidate.shared && destination.target === prior.resolved.target) continue;
|
|
266
|
+
throw new UnsafeEmittedPathError(
|
|
267
|
+
candidate.moduleName,
|
|
268
|
+
candidate.target,
|
|
269
|
+
`it collides with module '${prior.candidate.moduleName}' target '${prior.candidate.target}' after canonical resolution`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return resolved;
|
|
274
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One table-section authority for every declared engine dispatch path.
|
|
3
|
+
*
|
|
4
|
+
* F-041 existed because the production runner, module self-test runner and
|
|
5
|
+
* ejected runner each kept a different map and then fell back to the whole
|
|
6
|
+
* table. A missing entry therefore looked exactly like a green gate that had
|
|
7
|
+
* nothing to examine. Keep this file dependency-free so eject can copy the
|
|
8
|
+
* selector without pulling the CLI into the consumer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const WHOLE_TABLE = '__whole__';
|
|
12
|
+
|
|
13
|
+
export const ENGINE_TABLE_KEYS: Readonly<Record<string, string>> = Object.freeze({
|
|
14
|
+
'file-budget': 'file_budget',
|
|
15
|
+
sections: 'sections',
|
|
16
|
+
'frontmatter-schema': 'frontmatter_schema',
|
|
17
|
+
'link-integrity': 'link_integrity',
|
|
18
|
+
'file-population': 'file_population',
|
|
19
|
+
'gate-meta': 'gate_meta',
|
|
20
|
+
'id-integrity': WHOLE_TABLE,
|
|
21
|
+
'render-freshness': 'render_freshness',
|
|
22
|
+
'register-schema': 'register_schema',
|
|
23
|
+
'self-declared-closure': 'self_declared_closure',
|
|
24
|
+
'filename-schema': 'filename_schema',
|
|
25
|
+
'cross-reference': 'cross_reference',
|
|
26
|
+
'git-status-reconcile': 'merged_status',
|
|
27
|
+
'computed-claim': 'computed_claim',
|
|
28
|
+
'term-ownership': 'term_ownership',
|
|
29
|
+
'rule-propagation': 'rule_propagation',
|
|
30
|
+
'git-state': 'git_state',
|
|
31
|
+
'merge-driver-check': 'merge_driver_check',
|
|
32
|
+
'board-reconcile': 'board_reconcile',
|
|
33
|
+
'changelog-freshness': 'changelog_freshness',
|
|
34
|
+
'change-requires-file': 'change_requires_file',
|
|
35
|
+
'shell-safety': 'shell_safety',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const entryMatches = (entry: any, gateId: string) =>
|
|
39
|
+
!!entry?.id && gateId.includes(String(entry.id));
|
|
40
|
+
|
|
41
|
+
/** Select exactly the section an engine declared, or refuse an unknown shape. */
|
|
42
|
+
export function selectEngineTable(raw: any, engine: string, gateId: string): any {
|
|
43
|
+
if (!Object.prototype.hasOwnProperty.call(ENGINE_TABLE_KEYS, engine)) {
|
|
44
|
+
throw new Error(`engine '${engine}' has no table-section mapping`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const key = ENGINE_TABLE_KEYS[engine];
|
|
48
|
+
if (key === WHOLE_TABLE) return raw;
|
|
49
|
+
if (!raw || typeof raw !== 'object' || !(key in raw)) {
|
|
50
|
+
throw new Error(`gate '${gateId}' requires table section '${key}' for engine '${engine}'`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const section = raw[key];
|
|
54
|
+
if (!Array.isArray(section)) return section;
|
|
55
|
+
|
|
56
|
+
const identified = section.filter((entry: any) => entry?.id);
|
|
57
|
+
if (!identified.length) return section;
|
|
58
|
+
const matched = identified.filter((entry: any) => entryMatches(entry, gateId));
|
|
59
|
+
// Some sections intentionally share one subject-named spec across sibling
|
|
60
|
+
// gates (`id = "rules"`). A matching id narrows an array; no match keeps the
|
|
61
|
+
// already-selected section. The dangerous fallback was from a missing
|
|
62
|
+
// *section* to the whole document, and that remains forbidden above.
|
|
63
|
+
return matched.length
|
|
64
|
+
? section.filter((entry: any) => !entry?.id || entryMatches(entry, gateId))
|
|
65
|
+
: section;
|
|
66
|
+
}
|
package/src/engines.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveParams, substitute } from './substitute.ts';
|
|
|
10
10
|
import {
|
|
11
11
|
computedClaim,
|
|
12
12
|
crossReference,
|
|
13
|
+
changeRequiresFile,
|
|
13
14
|
filenameSchema,
|
|
14
15
|
gitStatusReconcile,
|
|
15
16
|
idIntegrity,
|
|
@@ -18,6 +19,8 @@ import {
|
|
|
18
19
|
selfDeclaredClosure,
|
|
19
20
|
} from './engines2.ts';
|
|
20
21
|
import { boardReconcile, changelogFreshness, gitState, mergeDriverCheck, rulePropagation, termOwnership } from './engines3.ts';
|
|
22
|
+
import { selectEngineTable } from './engine-table.ts';
|
|
23
|
+
import { semanticText } from './text.ts';
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* Where the CLI's own `modules/` lives.
|
|
@@ -39,6 +42,8 @@ const CLI_MODULES = join(dirname(fileURLToPath(import.meta.url)), '..', 'modules
|
|
|
39
42
|
export interface Finding {
|
|
40
43
|
file?: string;
|
|
41
44
|
message: string;
|
|
45
|
+
/** Stable comparison form when the displayed diagnostic carries environment-specific detail. */
|
|
46
|
+
identity?: string;
|
|
42
47
|
}
|
|
43
48
|
export interface EngineResult {
|
|
44
49
|
findings: Finding[];
|
|
@@ -49,7 +54,7 @@ export type Engine = (table: any, repoRoot: string, files: string[]) => EngineRe
|
|
|
49
54
|
|
|
50
55
|
const read = (root: string, rel: string) => {
|
|
51
56
|
try {
|
|
52
|
-
return readFileSync(join(root, rel), 'utf8');
|
|
57
|
+
return semanticText(readFileSync(join(root, rel), 'utf8'));
|
|
53
58
|
} catch {
|
|
54
59
|
return '';
|
|
55
60
|
}
|
|
@@ -371,7 +376,7 @@ export const gateMeta: Engine = (_t, root) => {
|
|
|
371
376
|
|
|
372
377
|
const registry = join(root, '.ai', 'gates.toml');
|
|
373
378
|
if (!existsSync(registry)) return { findings, examined: 0 };
|
|
374
|
-
const text = readFileSync(registry, 'utf8');
|
|
379
|
+
const text = semanticText(readFileSync(registry, 'utf8'));
|
|
375
380
|
const entries = [...text.matchAll(/\[\[gates\]\][\s\S]*?(?=\n\[\[gates\]\]|\n# rungs:end|$)/g)].map((m) => m[0]);
|
|
376
381
|
let examined = 0;
|
|
377
382
|
for (const entry of entries) {
|
|
@@ -382,7 +387,7 @@ export const gateMeta: Engine = (_t, root) => {
|
|
|
382
387
|
examined++;
|
|
383
388
|
// Tables live in the CLI, not the repo, so read them from the module set.
|
|
384
389
|
const tablePath = join(CLI_MODULES, dirname(table), 'gates', table.split('/').pop()!);
|
|
385
|
-
const src = existsSync(tablePath) ? readFileSync(tablePath, 'utf8') : '';
|
|
390
|
+
const src = existsSync(tablePath) ? semanticText(readFileSync(tablePath, 'utf8')) : '';
|
|
386
391
|
const forGate = [...src.matchAll(/\[\[self_test\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g)]
|
|
387
392
|
.map((m) => m[0])
|
|
388
393
|
.filter((b) => b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`));
|
|
@@ -412,9 +417,14 @@ export const gateMeta: Engine = (_t, root) => {
|
|
|
412
417
|
const blocks = (Array.isArray(parsed.self_test) ? parsed.self_test : [])
|
|
413
418
|
.filter((b: any) => b?.gate === id)
|
|
414
419
|
.map((b: any) => ({ expect: String(b.expect), input: b.input, fixture: b.fixture }));
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
420
|
+
try {
|
|
421
|
+
const section = selectEngineTable(parsed, engine, id);
|
|
422
|
+
for (const r of runSelfTests(id, engine, section, blocks)) {
|
|
423
|
+
if (r.outcome === 'mismatch') findings.push({ message: `self-test for '${id}' ${r.detail}` });
|
|
424
|
+
else if (r.outcome === 'unrun') unrun++;
|
|
425
|
+
}
|
|
426
|
+
} catch (e: any) {
|
|
427
|
+
findings.push({ message: `gate '${id}' table dispatch failed: ${e.message}` });
|
|
418
428
|
}
|
|
419
429
|
}
|
|
420
430
|
}
|
|
@@ -471,34 +481,12 @@ function parseTable(path: string, module: string): any | null {
|
|
|
471
481
|
// the same parameters or neither means anything.
|
|
472
482
|
const mods = loadAllModules(CLI_MODULES);
|
|
473
483
|
const params = resolveParams(mods, {}, '.');
|
|
474
|
-
return parseToml(substitute(readFileSync(path, 'utf8'), module, params));
|
|
484
|
+
return parseToml(substitute(semanticText(readFileSync(path, 'utf8')), module, params));
|
|
475
485
|
} catch {
|
|
476
486
|
return null;
|
|
477
487
|
}
|
|
478
488
|
}
|
|
479
489
|
|
|
480
|
-
/** Duplicated from `check.ts` rather than imported, to keep engines dependency-free of the runner. */
|
|
481
|
-
const tableKeyFor = (engine: string) =>
|
|
482
|
-
({
|
|
483
|
-
'file-budget': 'file_budget',
|
|
484
|
-
'frontmatter-schema': 'frontmatter_schema',
|
|
485
|
-
'link-integrity': 'link_integrity',
|
|
486
|
-
'file-population': 'file_population',
|
|
487
|
-
'render-freshness': 'render_freshness',
|
|
488
|
-
'register-schema': 'register_schema',
|
|
489
|
-
'self-declared-closure': 'self_declared_closure',
|
|
490
|
-
'filename-schema': 'filename_schema',
|
|
491
|
-
'cross-reference': 'cross_reference',
|
|
492
|
-
'git-status-reconcile': 'merged_status',
|
|
493
|
-
'computed-claim': 'computed_claim',
|
|
494
|
-
'term-ownership': 'term_ownership',
|
|
495
|
-
'rule-propagation': 'rule_propagation',
|
|
496
|
-
'git-state': 'git_state',
|
|
497
|
-
'merge-driver-check': 'merge_driver_check',
|
|
498
|
-
'board-reconcile': 'board_reconcile',
|
|
499
|
-
'changelog-freshness': 'changelog_freshness',
|
|
500
|
-
})[engine] ?? engine;
|
|
501
|
-
|
|
502
490
|
export const ENGINES: Record<string, Engine> = {
|
|
503
491
|
'file-budget': fileBudget,
|
|
504
492
|
sections,
|
|
@@ -520,6 +508,7 @@ export const ENGINES: Record<string, Engine> = {
|
|
|
520
508
|
'merge-driver-check': mergeDriverCheck,
|
|
521
509
|
'board-reconcile': boardReconcile,
|
|
522
510
|
'changelog-freshness': changelogFreshness,
|
|
511
|
+
'change-requires-file': changeRequiresFile,
|
|
523
512
|
};
|
|
524
513
|
|
|
525
514
|
/**
|