skill-family-engineering-kit 0.1.2 → 0.1.3
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/CONTRIBUTING.md +13 -67
- package/README.md +3 -3
- package/SECURITY.md +4 -9
- package/THIRD_PARTY_NOTICES +22 -9
- package/docs/404.html +27 -0
- package/docs/architecture/index.html +28 -1
- package/docs/git-lifecycle/index.html +27 -0
- package/docs/help/index.html +27 -0
- package/docs/index.html +27 -0
- package/docs/integration/audit/failure-evidence/index.html +27 -0
- package/docs/integration/audit/independence/index.html +27 -0
- package/docs/integration/audit/index.html +27 -0
- package/docs/integration/audit/mutation-taxonomy/index.html +27 -0
- package/docs/integration/audit/version-compatibility/index.html +27 -0
- package/docs/migration/index.html +31 -4
- package/docs/public/status/index.html +858 -0
- package/docs/quickstart/index.html +27 -0
- package/docs/search/search_index.json +1 -1
- package/docs/setup/index.html +27 -0
- package/docs/sitemap.xml +4 -0
- package/package.json +5 -5
- package/src/check.mjs +211 -76
- package/src/core-check.mjs +223 -0
- package/src/errors.mjs +8 -0
- package/src/index.mjs +13 -5
- package/src/skeleton.mjs +19 -79
- package/src/workspace.mjs +82 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { KIT_ERROR_KINDS } from "./errors.mjs";
|
|
4
|
+
import { CHECK_CLASSES, runChecks } from "./check.mjs";
|
|
5
|
+
import {
|
|
6
|
+
IDENTITY_RECORD_PATH,
|
|
7
|
+
MANAGED_LOCK_PATH,
|
|
8
|
+
PROJECT_MANIFEST_PATH,
|
|
9
|
+
} from "./skeleton.mjs";
|
|
10
|
+
import {
|
|
11
|
+
listTargetEntries,
|
|
12
|
+
loadTargetFacts,
|
|
13
|
+
matchAnyGitignorePattern,
|
|
14
|
+
matchAnyGlob,
|
|
15
|
+
resolveTargetRoot,
|
|
16
|
+
} from "./workspace.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* runCoreCheck — the single shared production entry of the closed-world
|
|
20
|
+
* project check (C2).
|
|
21
|
+
*
|
|
22
|
+
* The generated `scripts/check-core.mjs` entrypoints of scaffolded projects
|
|
23
|
+
* ONLY import this function: safe file-tree enumeration, lstat/symlink/FIFO
|
|
24
|
+
* and special-entry rejection, file-registry classification (managed /
|
|
25
|
+
* handwritten / artifact closed world), declaration path containment, the
|
|
26
|
+
* seven check classes, and the stable finding/exit mapping all live here and
|
|
27
|
+
* nowhere else. No generated string may re-derive a recursive walk, an lstat
|
|
28
|
+
* classification, a glob match, or a local closed-world algorithm.
|
|
29
|
+
*
|
|
30
|
+
* Stages (all read-only; never opens a file before it is lstat-classified):
|
|
31
|
+
* 1. declaration containment — escaping managed declarations (absolute
|
|
32
|
+
* paths, `..` segments) are reported lexically and never touch the fs;
|
|
33
|
+
* 2. managed preflight — lstat only: missing / symlink / non-regular
|
|
34
|
+
* managed paths fail closed before anything could read them;
|
|
35
|
+
* 3. tree enumeration — every symlink and special entry fails closed;
|
|
36
|
+
* every regular file must belong to the registered closed world
|
|
37
|
+
* (managed, handwritten, artifact, or a tracked tool lock);
|
|
38
|
+
* 4. the seven check classes through runChecks — skipped whenever an
|
|
39
|
+
* unsafe path (symlink/special at a managed or class-read path) would
|
|
40
|
+
* be opened by them: FIFOs must never be read (they block), symlinks
|
|
41
|
+
* must never be followed.
|
|
42
|
+
*
|
|
43
|
+
* Exit mapping: 0 clean; 2 when any finding carries a security kind
|
|
44
|
+
* (CORE_CHECK_SECURITY_KINDS, including the harness containment kinds that
|
|
45
|
+
* can surface through escaping lock entries); 1 otherwise.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** Finding kinds that always map to the security exit code 2. */
|
|
49
|
+
export const CORE_CHECK_SECURITY_KINDS = Object.freeze([
|
|
50
|
+
// Kit kinds of this entry.
|
|
51
|
+
KIT_ERROR_KINDS.SYMLINK_AT_MANAGED_PATH,
|
|
52
|
+
KIT_ERROR_KINDS.NOT_A_REGULAR_FILE,
|
|
53
|
+
KIT_ERROR_KINDS.SYMLINK_ENTRY,
|
|
54
|
+
KIT_ERROR_KINDS.SPECIAL_ENTRY,
|
|
55
|
+
KIT_ERROR_KINDS.UNCONTAINED_DECLARATION,
|
|
56
|
+
// Stable harness containment kinds that surface through drift findings when
|
|
57
|
+
// a lock entry tries to escape the target (reported, never followed).
|
|
58
|
+
"absolute-path",
|
|
59
|
+
"windows-drive-path",
|
|
60
|
+
"windows-path",
|
|
61
|
+
"unc-path",
|
|
62
|
+
"path-traversal",
|
|
63
|
+
"symlink-escape",
|
|
64
|
+
"realpath-escape",
|
|
65
|
+
"invalid-path",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const CORE_CLASS = "core";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Paths the seven check classes open by name. They take part in the unsafe
|
|
72
|
+
* read-set even when they are not declared managed (README.md is handwritten
|
|
73
|
+
* in the skeleton), so a symlink/FIFO there can never hang or escape a read.
|
|
74
|
+
*/
|
|
75
|
+
const CLASS_READ_PATHS = Object.freeze([
|
|
76
|
+
"README.md",
|
|
77
|
+
"package.json",
|
|
78
|
+
PROJECT_MANIFEST_PATH,
|
|
79
|
+
MANAGED_LOCK_PATH,
|
|
80
|
+
IDENTITY_RECORD_PATH,
|
|
81
|
+
"identity-record.json",
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/** Lexical containment of a managed declaration; never touches the fs. */
|
|
85
|
+
export function isContainedDeclaration(rel) {
|
|
86
|
+
if (typeof rel !== "string" || rel.length === 0) return false;
|
|
87
|
+
if (rel.startsWith("/")) return false;
|
|
88
|
+
if (rel.split("/").includes("..")) return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Runs the unified closed-world check over one target.
|
|
94
|
+
* Options: { root, allowGitSpawn, profilesRoot }.
|
|
95
|
+
* Returns { kind, ok, exitCode, findings, classes, unsafeReadPaths }.
|
|
96
|
+
* Never writes anywhere.
|
|
97
|
+
*/
|
|
98
|
+
export async function runCoreCheck({ root, allowGitSpawn = true, profilesRoot } = {}) {
|
|
99
|
+
const rootAbs = await resolveTargetRoot(root ?? ".");
|
|
100
|
+
const facts = await loadTargetFacts(rootAbs);
|
|
101
|
+
const findings = [];
|
|
102
|
+
const push = (kind, message, extra) =>
|
|
103
|
+
findings.push({ class: CORE_CLASS, kind, code: "SFC2004", message, ...(extra ?? {}) });
|
|
104
|
+
|
|
105
|
+
// ---- Stage 1: declaration containment (purely lexical) -----------------
|
|
106
|
+
const declarations = [...facts.managedSet].sort();
|
|
107
|
+
const containedDeclarations = [];
|
|
108
|
+
for (const rel of declarations) {
|
|
109
|
+
if (isContainedDeclaration(rel)) {
|
|
110
|
+
containedDeclarations.push(rel);
|
|
111
|
+
} else {
|
|
112
|
+
push(
|
|
113
|
+
KIT_ERROR_KINDS.UNCONTAINED_DECLARATION,
|
|
114
|
+
`managed declaration escapes the target root (absolute path or '..' segment): ${rel}`,
|
|
115
|
+
{ path: rel },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- Stage 2: managed preflight (lstat only, never open/read) ----------
|
|
121
|
+
const unsafeReadPaths = new Set();
|
|
122
|
+
for (const rel of containedDeclarations) {
|
|
123
|
+
let st;
|
|
124
|
+
try {
|
|
125
|
+
st = await lstat(path.join(rootAbs, rel));
|
|
126
|
+
} catch {
|
|
127
|
+
push(
|
|
128
|
+
KIT_ERROR_KINDS.MANAGED_FILE_MISSING,
|
|
129
|
+
`managed file declared in the registry/manifest/lock does not exist: ${rel}`,
|
|
130
|
+
{ path: rel },
|
|
131
|
+
);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (st.isSymbolicLink()) {
|
|
135
|
+
push(KIT_ERROR_KINDS.SYMLINK_AT_MANAGED_PATH, `managed path is a symbolic link: ${rel}`, { path: rel });
|
|
136
|
+
unsafeReadPaths.add(rel);
|
|
137
|
+
} else if (!st.isFile()) {
|
|
138
|
+
push(
|
|
139
|
+
KIT_ERROR_KINDS.NOT_A_REGULAR_FILE,
|
|
140
|
+
`managed path is not a regular file (FIFO/socket/device/directory): ${rel}`,
|
|
141
|
+
{ path: rel },
|
|
142
|
+
);
|
|
143
|
+
unsafeReadPaths.add(rel);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- Stage 3: safe tree enumeration + closed-world classification ------
|
|
148
|
+
const entries = await listTargetEntries(rootAbs);
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (entry.kind === "directory" || entry.kind === "directory-opaque") continue;
|
|
151
|
+
if (entry.kind === "symlink") {
|
|
152
|
+
// Symlinks are never classified by content patterns: unregistered ones
|
|
153
|
+
// fail closed; managed ones were already reported by the preflight.
|
|
154
|
+
if (!facts.managedSet.has(entry.path)) {
|
|
155
|
+
push(
|
|
156
|
+
KIT_ERROR_KINDS.SYMLINK_ENTRY,
|
|
157
|
+
`symbolic link is outside the registered closed world (managed/handwritten/artifact): ${entry.path}`,
|
|
158
|
+
{ path: entry.path },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (facts.managedSet.has(entry.path) || CLASS_READ_PATHS.includes(entry.path)) {
|
|
162
|
+
unsafeReadPaths.add(entry.path);
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (entry.kind === "special") {
|
|
167
|
+
if (!facts.managedSet.has(entry.path)) {
|
|
168
|
+
push(
|
|
169
|
+
KIT_ERROR_KINDS.SPECIAL_ENTRY,
|
|
170
|
+
`special entry (FIFO/socket/device) is outside the registered closed world: ${entry.path}`,
|
|
171
|
+
{ path: entry.path },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (facts.managedSet.has(entry.path) || CLASS_READ_PATHS.includes(entry.path)) {
|
|
175
|
+
unsafeReadPaths.add(entry.path);
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Regular files must belong to exactly one registered class. Managed
|
|
180
|
+
// bytes are verified by the drift class, never re-derived here.
|
|
181
|
+
if (facts.managedSet.has(entry.path)) continue;
|
|
182
|
+
if (matchAnyGlob(facts.handwrittenPatterns, entry.path)) continue;
|
|
183
|
+
if (matchAnyGitignorePattern(facts.artifactPatterns, entry.path)) continue;
|
|
184
|
+
if (facts.trackedToolLocks.includes(entry.path)) continue;
|
|
185
|
+
push(
|
|
186
|
+
KIT_ERROR_KINDS.UNREGISTERED_FILE,
|
|
187
|
+
`regular file is not registered in the closed world (managed/handwritten/artifact): ${entry.path}`,
|
|
188
|
+
{ path: entry.path },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- Stage 4: the seven check classes through the shared entry ---------
|
|
193
|
+
let classReport = null;
|
|
194
|
+
if (unsafeReadPaths.size === 0) {
|
|
195
|
+
classReport = await runChecks({ root: rootAbs, allowGitSpawn, profilesRoot });
|
|
196
|
+
findings.push(...classReport.findings);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let exitCode = 0;
|
|
200
|
+
if (findings.length > 0) {
|
|
201
|
+
const securityKinds = new Set(CORE_CHECK_SECURITY_KINDS);
|
|
202
|
+
exitCode = findings.some((f) => securityKinds.has(f.kind)) ? 2 : 1;
|
|
203
|
+
}
|
|
204
|
+
if (classReport && classReport.mechanism) exitCode = 2;
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
kind: "skill-family.core-check-report",
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
target: { root: ".", entryCount: entries.length },
|
|
210
|
+
ok: findings.length === 0,
|
|
211
|
+
exitCode,
|
|
212
|
+
findings,
|
|
213
|
+
classes:
|
|
214
|
+
classReport?.classes ??
|
|
215
|
+
CHECK_CLASSES.map((name) => ({ name, selected: false, completed: false, findings: 0 })),
|
|
216
|
+
classChecksSkipped:
|
|
217
|
+
unsafeReadPaths.size > 0
|
|
218
|
+
? { reason: "unsafe read-set (symlink/special at a managed or class-read path)", paths: [...unsafeReadPaths].sort() }
|
|
219
|
+
: null,
|
|
220
|
+
policy:
|
|
221
|
+
"core check is diagnosis only: it enumerates with lstat, never opens an unclassified entry, never follows symlinks, and never writes",
|
|
222
|
+
};
|
|
223
|
+
}
|
package/src/errors.mjs
CHANGED
|
@@ -48,9 +48,12 @@ export const KIT_ERROR_KINDS = Object.freeze({
|
|
|
48
48
|
// check / shared
|
|
49
49
|
CONTRACTS_MISSING: "contracts-missing",
|
|
50
50
|
CONTRACT_PARSE_FAILED: "contract-parse-failed",
|
|
51
|
+
DOCUMENT_INCOMPLETE: "document-incomplete",
|
|
52
|
+
CHECK_CLASS_FAILED: "check-class-failed",
|
|
51
53
|
MANAGED_FILE_MISSING: "managed-file-missing",
|
|
52
54
|
MANAGED_FILE_DRIFT: "managed-file-drift",
|
|
53
55
|
SYMLINK_AT_MANAGED_PATH: "symlink-at-managed-path",
|
|
56
|
+
NOT_A_REGULAR_FILE: "not-a-regular-file",
|
|
54
57
|
CLOSURE_INPUT_MISSING: "closure-input-missing",
|
|
55
58
|
CONTRACTS_VERSION_MISMATCH: "contracts-version-mismatch",
|
|
56
59
|
README_MISSING: "readme-missing",
|
|
@@ -59,6 +62,11 @@ export const KIT_ERROR_KINDS = Object.freeze({
|
|
|
59
62
|
GIT_NO_COMMITS: "git-no-commits",
|
|
60
63
|
GIT_DIRTY: "git-dirty",
|
|
61
64
|
MUTATION_MODE_REQUESTED: "mutation-mode-requested",
|
|
65
|
+
// core check: the single shared closed-world entry (C2)
|
|
66
|
+
UNREGISTERED_FILE: "unregistered-file",
|
|
67
|
+
SYMLINK_ENTRY: "symlink-entry",
|
|
68
|
+
SPECIAL_ENTRY: "special-entry",
|
|
69
|
+
UNCONTAINED_DECLARATION: "uncontained-declaration",
|
|
62
70
|
});
|
|
63
71
|
|
|
64
72
|
/** Error carrying one frozen SFC code plus a stable kit kind in details. */
|
package/src/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ContractsError } from "skill-family-contracts";
|
|
2
2
|
import { buildHandoffDraft, HANDOFF_FIELDS, planAdoption } from "./adopt-plan.mjs";
|
|
3
|
-
import { CHECK_CLASSES, runChecks } from "./check.mjs";
|
|
3
|
+
import { CHECK_CLASSES, DOCUMENT_STATES, runChecks } from "./check.mjs";
|
|
4
|
+
import { CORE_CHECK_SECURITY_KINDS, isContainedDeclaration, runCoreCheck } from "./core-check.mjs";
|
|
4
5
|
import {
|
|
5
6
|
invalidParamsError,
|
|
6
7
|
KitError,
|
|
@@ -150,10 +151,13 @@ export async function runCommand(command, options = {}) {
|
|
|
150
151
|
}
|
|
151
152
|
case "check": {
|
|
152
153
|
const output = await runChecks(options);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
// 0 clean; 2 mechanism (a selected class could not complete); 1 findings.
|
|
155
|
+
const exitCode = output.mechanism
|
|
156
|
+
? KIT_EXIT_CODES.rejected
|
|
157
|
+
: output.ok
|
|
158
|
+
? KIT_EXIT_CODES.ok
|
|
159
|
+
: KIT_EXIT_CODES.findings;
|
|
160
|
+
return { exitCode, output };
|
|
157
161
|
}
|
|
158
162
|
default: {
|
|
159
163
|
// TOP_LEVEL_COMMANDS is frozen with exactly the cases above; this
|
|
@@ -181,6 +185,10 @@ export {
|
|
|
181
185
|
loadProjectionManifest,
|
|
182
186
|
runChecks,
|
|
183
187
|
CHECK_CLASSES,
|
|
188
|
+
DOCUMENT_STATES,
|
|
189
|
+
runCoreCheck,
|
|
190
|
+
CORE_CHECK_SECURITY_KINDS,
|
|
191
|
+
isContainedDeclaration,
|
|
184
192
|
probeGitState,
|
|
185
193
|
probeGitFacts,
|
|
186
194
|
GIT_READ_ONLY_ALLOWLIST,
|
package/src/skeleton.mjs
CHANGED
|
@@ -379,88 +379,28 @@ function renderDocsDeployWorkflow() {
|
|
|
379
379
|
}
|
|
380
380
|
|
|
381
381
|
function renderCheckCoreScript() {
|
|
382
|
+
// Thin generated shim. ALL mechanics — safe tree enumeration, entry-type
|
|
383
|
+
// rejection (symlink / FIFO / special entries), file-registry closed-world
|
|
384
|
+
// classification, path containment, the seven check classes, and the
|
|
385
|
+
// stable finding/exit mapping — live in the kit's single shared production
|
|
386
|
+
// entry (runCoreCheck). This file must never grow a local tree scan,
|
|
387
|
+
// entry-type classifier, glob matcher, or closed-world algorithm of its own.
|
|
382
388
|
return [
|
|
383
389
|
"#!/usr/bin/env node",
|
|
384
|
-
"// core check:
|
|
385
|
-
"//
|
|
386
|
-
"//
|
|
387
|
-
"//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
'import {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
"",
|
|
394
|
-
|
|
395
|
-
"const failures = [];",
|
|
396
|
-
"",
|
|
397
|
-
"// Load file registry for closed-world classification",
|
|
398
|
-
'const registry = JSON.parse(readFileSync(path.join(root, ".foundation/file-registry.json"), "utf8"));',
|
|
399
|
-
'const lock = JSON.parse(readFileSync(path.join(root, "skill-family.managed-file-lock.json"), "utf8"));',
|
|
400
|
-
"",
|
|
401
|
-
"// Build managed file set from file registry (projenManaged + kitManaged)",
|
|
402
|
-
"const managedSet = new Set([",
|
|
403
|
-
" ...registry.classes.projenManaged.files,",
|
|
404
|
-
" ...registry.classes.kitManaged.files,",
|
|
405
|
-
"]);",
|
|
406
|
-
"",
|
|
407
|
-
"// Build handwritten patterns from registry",
|
|
408
|
-
"const handwrittenPatterns = registry.classes.handwritten.entries.filter((e) => typeof e === \"string\");",
|
|
409
|
-
"",
|
|
410
|
-
"// Artifact patterns from registry",
|
|
411
|
-
"const artifactPatterns = registry.classes.artifacts.patterns.filter((e) => typeof e === \"string\");",
|
|
412
|
-
"",
|
|
413
|
-
"// Pre-flight: verify managed files are regular files (catches symlinks, FIFOs, etc.)",
|
|
414
|
-
"// This prevents runChecks from hanging on FIFOs and provides clear error messages.",
|
|
415
|
-
"for (const relPath of managedSet) {",
|
|
416
|
-
" let st;",
|
|
417
|
-
" try {",
|
|
418
|
-
" st = await lstat(path.resolve(root, relPath));",
|
|
419
|
-
" } catch {",
|
|
420
|
-
' failures.push(`managed file missing: ${relPath}`);',
|
|
421
|
-
" continue;",
|
|
422
|
-
" }",
|
|
423
|
-
" if (st.isSymbolicLink()) {",
|
|
424
|
-
' failures.push(`managed file is a symlink: ${relPath}`);',
|
|
425
|
-
" } else if (!st.isFile()) {",
|
|
426
|
-
' failures.push(`managed file is not a regular file: ${relPath}`);',
|
|
427
|
-
" }",
|
|
428
|
-
"}",
|
|
429
|
-
"",
|
|
430
|
-
"// Closed-world file classification: every tracked file must be classified",
|
|
431
|
-
"const OPAQUE = new Set([\".git\", \"node_modules\"]);",
|
|
432
|
-
"async function walk(dir, base) {",
|
|
433
|
-
" const dirents = await readdir(dir, { withFileTypes: true });",
|
|
434
|
-
" for (const dirent of dirents) {",
|
|
435
|
-
" const rel = base ? `${base}/${dirent.name}` : dirent.name;",
|
|
436
|
-
" if (dirent.isDirectory() && !dirent.isSymbolicLink()) {",
|
|
437
|
-
" if (!OPAQUE.has(dirent.name)) await walk(path.join(dir, dirent.name), rel);",
|
|
438
|
-
" continue;",
|
|
439
|
-
" }",
|
|
440
|
-
" if (!dirent.isFile()) continue;",
|
|
441
|
-
" const isManaged = managedSet.has(rel);",
|
|
442
|
-
" const isHandwritten = matchAnyGlob(handwrittenPatterns, rel);",
|
|
443
|
-
" const isArtifact = matchAnyGlob(artifactPatterns, rel);",
|
|
444
|
-
" if (!isManaged && !isHandwritten && !isArtifact) {",
|
|
445
|
-
' failures.push(`unregistered file: ${rel}`);',
|
|
446
|
-
" }",
|
|
447
|
-
" }",
|
|
448
|
-
"}",
|
|
449
|
-
"await walk(root, \"\");",
|
|
450
|
-
"",
|
|
451
|
-
"// Run the shared production checks (schema, drift, closure, version, docs, git, identity)",
|
|
452
|
-
"if (failures.length === 0) {",
|
|
453
|
-
" const report = await runChecks({ root, allowGitSpawn: true });",
|
|
454
|
-
" for (const f of report.findings) {",
|
|
455
|
-
' failures.push(`${f.class}: ${f.message}`);',
|
|
456
|
-
" }",
|
|
457
|
-
"}",
|
|
458
|
-
"",
|
|
459
|
-
"if (failures.length > 0) {",
|
|
460
|
-
' for (const f of failures) console.error(`[check-core] ${f}`);',
|
|
461
|
-
" process.exit(failures.some((f) => f.includes(\"symlink\") || f.includes(\"not a regular\") || f.includes(\"traversal\") || f.includes(\"escape\")) ? 2 : 1);",
|
|
390
|
+
"// core check: generated shim. It only imports the Foundation's single",
|
|
391
|
+
"// shared production check entry (runCoreCheck), passes the target root,",
|
|
392
|
+
"// prints the structured findings, and maps the stable exit code.",
|
|
393
|
+
"// No local algorithm duplication: safe tree enumeration, symlink/FIFO/",
|
|
394
|
+
"// special-entry rejection, closed-world classification, path containment,",
|
|
395
|
+
"// and the seven check classes all live in skill-family-engineering-kit.",
|
|
396
|
+
'import { runCoreCheck } from "skill-family-engineering-kit";',
|
|
397
|
+
"",
|
|
398
|
+
"const report = await runCoreCheck({ root: process.cwd(), allowGitSpawn: true });",
|
|
399
|
+
"for (const f of report.findings) {",
|
|
400
|
+
' console.error(`[check-core] ${f.class}: ${f.kind}: ${f.message}`);',
|
|
462
401
|
"}",
|
|
463
|
-
|
|
402
|
+
"if (!report.ok) process.exit(report.exitCode);",
|
|
403
|
+
'console.log("[check-core] OK");',
|
|
464
404
|
"",
|
|
465
405
|
].join("\n");
|
|
466
406
|
}
|
package/src/workspace.mjs
CHANGED
|
@@ -101,7 +101,11 @@ export async function listTargetEntries(root) {
|
|
|
101
101
|
}
|
|
102
102
|
if (dirent.isFile()) {
|
|
103
103
|
entries.push({ path: rel, kind: "file" });
|
|
104
|
+
continue;
|
|
104
105
|
}
|
|
106
|
+
// Anything else (FIFO, socket, device, …) is a special entry: it is
|
|
107
|
+
// recorded so classification can fail closed on it; it is never opened.
|
|
108
|
+
entries.push({ path: rel, kind: "special" });
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
await walk(root, "");
|
|
@@ -169,6 +173,61 @@ export function matchAnyGlob(patterns, relPath) {
|
|
|
169
173
|
return Array.isArray(patterns) && patterns.some((pattern) => matchGlob(pattern, relPath));
|
|
170
174
|
}
|
|
171
175
|
|
|
176
|
+
function gitignoreSegmentRegExp(segment) {
|
|
177
|
+
let expression = "";
|
|
178
|
+
for (const char of segment) {
|
|
179
|
+
if (char === "*") expression += "[^/]*";
|
|
180
|
+
else expression += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
181
|
+
}
|
|
182
|
+
return new RegExp(`^${expression}$`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Gitignore-style lexical match for file-registry artifact patterns. The
|
|
187
|
+
* registry's artifacts class is mechanically identical to the .gitignore
|
|
188
|
+
* patterns (check-structure enforces the equality), so the closed-world
|
|
189
|
+
* classification interprets them with gitignore semantics:
|
|
190
|
+
* - a leading "/" anchors the pattern at the target root;
|
|
191
|
+
* - a "/" in the middle (after the trailing one is stripped) also anchors;
|
|
192
|
+
* - a trailing "/" marks a directory pattern: it matches everything under
|
|
193
|
+
* a directory with that name, never a regular file of that name;
|
|
194
|
+
* - "*" is a per-segment wildcard (never crosses "/");
|
|
195
|
+
* - unanchored patterns match at any depth.
|
|
196
|
+
* Purely lexical; never touches the filesystem.
|
|
197
|
+
*/
|
|
198
|
+
export function matchGitignoreStylePattern(pattern, relPath) {
|
|
199
|
+
if (typeof pattern !== "string" || typeof relPath !== "string") return false;
|
|
200
|
+
let trimmed = pattern.trim();
|
|
201
|
+
if (trimmed === "" || trimmed.startsWith("#")) return false;
|
|
202
|
+
let rootAnchored = trimmed.startsWith("/");
|
|
203
|
+
if (rootAnchored) trimmed = trimmed.slice(1);
|
|
204
|
+
const directoryOnly = trimmed.endsWith("/");
|
|
205
|
+
if (directoryOnly) trimmed = trimmed.slice(0, -1);
|
|
206
|
+
if (trimmed.includes("/")) rootAnchored = true;
|
|
207
|
+
const patternSegments = trimmed.split("/").filter((segment) => segment !== "");
|
|
208
|
+
if (patternSegments.length === 0) return false;
|
|
209
|
+
const pathSegments = relPath.split("/").filter((segment) => segment !== "");
|
|
210
|
+
const matchesAt = (start) => {
|
|
211
|
+
if (start + patternSegments.length > pathSegments.length) return false;
|
|
212
|
+
for (let i = 0; i < patternSegments.length; i += 1) {
|
|
213
|
+
if (!gitignoreSegmentRegExp(patternSegments[i]).test(pathSegments[start + i])) return false;
|
|
214
|
+
}
|
|
215
|
+
const end = start + patternSegments.length;
|
|
216
|
+
// A directory pattern matches everything strictly under the directory;
|
|
217
|
+
// a file pattern matches exactly the named entry.
|
|
218
|
+
return directoryOnly ? end < pathSegments.length : end === pathSegments.length;
|
|
219
|
+
};
|
|
220
|
+
if (rootAnchored) return matchesAt(0);
|
|
221
|
+
for (let start = 0; start < pathSegments.length; start += 1) {
|
|
222
|
+
if (matchesAt(start)) return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function matchAnyGitignorePattern(patterns, relPath) {
|
|
228
|
+
return Array.isArray(patterns) && patterns.some((pattern) => matchGitignoreStylePattern(pattern, relPath));
|
|
229
|
+
}
|
|
230
|
+
|
|
172
231
|
/** Normalizes a relative path to forward slashes without touching the fs. */
|
|
173
232
|
export function normalizeRelPath(relPath) {
|
|
174
233
|
return String(relPath).replaceAll("\\", "/").replace(/\/+/g, "/").replace(/^\.\//, "");
|
|
@@ -189,6 +248,8 @@ export async function loadTargetFacts(root) {
|
|
|
189
248
|
managedLock: null,
|
|
190
249
|
managedSet: new Set(),
|
|
191
250
|
handwrittenPatterns: [...DEFAULT_HANDWRITTEN_PATTERNS],
|
|
251
|
+
artifactPatterns: [],
|
|
252
|
+
trackedToolLocks: [],
|
|
192
253
|
hasOwnRegistry: false,
|
|
193
254
|
};
|
|
194
255
|
|
|
@@ -196,16 +257,37 @@ export async function loadTargetFacts(root) {
|
|
|
196
257
|
if (registry.ok && registry.value && typeof registry.value === "object") {
|
|
197
258
|
facts.fileRegistry = registry.value;
|
|
198
259
|
facts.hasOwnRegistry = true;
|
|
260
|
+
// Foundation-shape registry: one flat managed list.
|
|
199
261
|
const managed = registry.value?.classes?.managed;
|
|
200
262
|
if (Array.isArray(managed)) {
|
|
201
263
|
for (const entry of managed) {
|
|
202
264
|
if (typeof entry === "string") facts.managedSet.add(normalizeRelPath(entry));
|
|
203
265
|
}
|
|
204
266
|
}
|
|
267
|
+
// Generated-skeleton registry: the managed set is declared as two classes
|
|
268
|
+
// (projen-managed + kit-managed). Both shapes feed the same closed world.
|
|
269
|
+
for (const classKey of ["projenManaged", "kitManaged"]) {
|
|
270
|
+
const files = registry.value?.classes?.[classKey]?.files;
|
|
271
|
+
if (Array.isArray(files)) {
|
|
272
|
+
for (const entry of files) {
|
|
273
|
+
if (typeof entry === "string") facts.managedSet.add(normalizeRelPath(entry));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
205
277
|
const patterns = registry.value?.classes?.handwritten?.entries;
|
|
206
278
|
if (Array.isArray(patterns) && patterns.length > 0) {
|
|
207
279
|
facts.handwrittenPatterns = patterns.filter((entry) => typeof entry === "string");
|
|
208
280
|
}
|
|
281
|
+
const artifactPatterns = registry.value?.classes?.artifacts?.patterns;
|
|
282
|
+
if (Array.isArray(artifactPatterns)) {
|
|
283
|
+
facts.artifactPatterns = artifactPatterns.filter((entry) => typeof entry === "string");
|
|
284
|
+
}
|
|
285
|
+
const toolLocks = registry.value?.classes?.trackedToolLocks?.entries;
|
|
286
|
+
if (Array.isArray(toolLocks)) {
|
|
287
|
+
facts.trackedToolLocks = toolLocks
|
|
288
|
+
.filter((entry) => typeof entry === "string")
|
|
289
|
+
.map((entry) => normalizeRelPath(entry));
|
|
290
|
+
}
|
|
209
291
|
}
|
|
210
292
|
|
|
211
293
|
const manifest = await readOptionalJson(root, "skill-family.project-manifest.json");
|