skillrepo 4.12.0 → 4.14.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 +28 -1
- package/package.json +1 -1
- package/src/commands/list.mjs +46 -1
- package/src/commands/update.mjs +67 -2
- package/src/lib/foreign-content.mjs +259 -3
- package/src/lib/global-boundary.mjs +511 -0
- package/src/lib/repo-sync-state.mjs +9 -0
- package/src/lib/sync.mjs +393 -26
- package/src/test/commands/list.test.mjs +130 -0
- package/src/test/commands/update.test.mjs +106 -0
- package/src/test/lib/foreign-content.test.mjs +172 -0
- package/src/test/lib/global-boundary.test.mjs +642 -0
- package/src/test/lib/sync-skillset.test.mjs +422 -2
- package/src/test/lib/sync.test.mjs +238 -0
package/README.md
CHANGED
|
@@ -119,11 +119,23 @@ and just write the config + gitignore.
|
|
|
119
119
|
"removed": 0,
|
|
120
120
|
"notModified": false,
|
|
121
121
|
"fullSync": true,
|
|
122
|
-
"syncedAt": "2026-05-01T00:00:00.000Z"
|
|
122
|
+
"syncedAt": "2026-05-01T00:00:00.000Z",
|
|
123
|
+
"globalBoundary": {
|
|
124
|
+
"total": 0,
|
|
125
|
+
"counts": { "global_library": 0, "global_foreign": 0, "global_shadowed": 0 },
|
|
126
|
+
"names": [],
|
|
127
|
+
"skillsetDeclared": false
|
|
128
|
+
}
|
|
123
129
|
}
|
|
124
130
|
}
|
|
125
131
|
```
|
|
126
132
|
|
|
133
|
+
`sync.globalBoundary` (4.13.0) appears on every successful
|
|
134
|
+
project-scope sync and describes the personal-scope skills that also
|
|
135
|
+
load in sessions here; it is absent when the scan did not run
|
|
136
|
+
(`--global`, or a scan failure). `names` stays local to your machine —
|
|
137
|
+
sync reporting to your organization carries counts only.
|
|
138
|
+
|
|
127
139
|
Field notes:
|
|
128
140
|
- `vendors` is the resolved canonical-key list, NOT the raw `--agent` input. `--agent agents` produces every cohort vendor (cursor, windsurf, gemini, codex, cline, copilot); `--agent none` produces an empty array.
|
|
129
141
|
- `sessionSync.cohortHooks[]` reports per-vendor outcomes for the auto-refresh hooks installed alongside the Claude Code SessionStart hook (one entry per cohort vendor with a non-null `agentHook` registry spec — Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). `reason` is present only when `action: "failed"`. Empty array when `--no-session-sync` was passed or no cohort vendor was selected.
|
|
@@ -183,6 +195,21 @@ Sync also warns — once per repo, same rules — when a `skillrepo.json`
|
|
|
183
195
|
at the repo root is gitignored: the skillset declaration only works as
|
|
184
196
|
a committed file, so remove it from `.gitignore` and commit it.
|
|
185
197
|
|
|
198
|
+
Project syncs also disclose the personal scope (the global boundary):
|
|
199
|
+
skills under the global folders (`~/.claude/skills/`,
|
|
200
|
+
`~/.agents/skills/`, `~/.codeium/windsurf/skills/`) load in every
|
|
201
|
+
session run inside a project, whether or not they came through a
|
|
202
|
+
skillset. When any exist, a one-line disclosure says how many global
|
|
203
|
+
skills will also load — in a declared repo it also counts how many sit
|
|
204
|
+
outside the repo's skillset and how many collide with a skillset
|
|
205
|
+
member's name. Today the session auto-sync prints that line in Claude
|
|
206
|
+
Code only (the other agents' background sync hooks run silently by
|
|
207
|
+
their hook contracts — see `docs/vendor-paths.md` for each vendor's
|
|
208
|
+
channel); `skillrepo list` shows the same line everywhere. Interactive
|
|
209
|
+
syncs add a one-time warning per finding with the remediation, and
|
|
210
|
+
your organization's sync reporting carries counts only
|
|
211
|
+
(`global_library`, `global_foreign`, `global_shadowed`), never names.
|
|
212
|
+
|
|
186
213
|
A repository can declare a skillset in a root `skillrepo.json`:
|
|
187
214
|
`{"skillset": {"version": 1, "name": "<repo-identity>", "use":
|
|
188
215
|
"owner/skillset-name"}}` (optional `extra: ["owner/skill"]`). The CLI
|
package/package.json
CHANGED
package/src/commands/list.mjs
CHANGED
|
@@ -43,6 +43,12 @@ import { detectAgents } from "../lib/detect-agents.mjs";
|
|
|
43
43
|
import { walkDetectedPlacements } from "../lib/placement-walk.mjs";
|
|
44
44
|
import { getAgentByKey } from "../lib/agent-registry.mjs";
|
|
45
45
|
import { computeSkillState, rollupState, SKILL_STATE } from "../lib/drift.mjs";
|
|
46
|
+
import {
|
|
47
|
+
scanGlobalBoundary,
|
|
48
|
+
formatGlobalBoundaryDisclosure,
|
|
49
|
+
managedGlobalNamesFrom,
|
|
50
|
+
resolveBoundaryMemberContext,
|
|
51
|
+
} from "../lib/global-boundary.mjs";
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
54
|
* Run `list`. Throws CliError on any failure.
|
|
@@ -56,7 +62,10 @@ export async function runList(argv, io = {}) {
|
|
|
56
62
|
const stdout = io.stdout ?? process.stdout;
|
|
57
63
|
const flags = resolveFlags(argv);
|
|
58
64
|
|
|
59
|
-
// `list` is a read-only drift check — it must NEVER set sync state
|
|
65
|
+
// `list` is a read-only drift check — it must NEVER set sync state,
|
|
66
|
+
// and (#2495) it never writes the governance-seen file either: the
|
|
67
|
+
// boundary disclosure below scans without committing warn-state, so
|
|
68
|
+
// running `list` can't consume a warning `update` owes the user.
|
|
60
69
|
// Use the manifest read (#1832): metadata only, no file bodies, and the
|
|
61
70
|
// server records no delivery for it. Per-skill drift is computed from
|
|
62
71
|
// on-disk SHAs + `.last-sync` below, never from the response body, so
|
|
@@ -122,6 +131,42 @@ export async function runList(argv, io = {}) {
|
|
|
122
131
|
|
|
123
132
|
printTable(augmented, detected, stdout);
|
|
124
133
|
printFooter(augmented, libraryResponse.etag, lastSync, stdout, canUseGlyphs(stdout));
|
|
134
|
+
printGlobalBoundaryDisclosure(detectedKeys, lastSync, stdout);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Global-boundary disclosure for the table surface (#2495): one line,
|
|
139
|
+
* same format as the session-hook disclosure, printed only when the
|
|
140
|
+
* detected vendors' GLOBAL roots hold skill dirs. Read-only to the
|
|
141
|
+
* letter of list's contract above — no seen-state commit, no per-dir
|
|
142
|
+
* warning lines (those are `update`'s interactive job). Best-effort:
|
|
143
|
+
* the drift table must never fail because a disclosure probe did.
|
|
144
|
+
*
|
|
145
|
+
* The declaration is resolved TOLERANTLY, like the throttled sync
|
|
146
|
+
* exit: `list` is a reporting surface, so an invalid `skillrepo.json`
|
|
147
|
+
* stays `update`'s error to raise — here it just means the scan runs
|
|
148
|
+
* without a member set.
|
|
149
|
+
*
|
|
150
|
+
* @param {string[]} vendors - Detected vendor keys.
|
|
151
|
+
* @param {import("../lib/sync.mjs").SyncStateFile | null} lastSync
|
|
152
|
+
* @param {NodeJS.WritableStream} stdout
|
|
153
|
+
*/
|
|
154
|
+
function printGlobalBoundaryDisclosure(vendors, lastSync, stdout) {
|
|
155
|
+
try {
|
|
156
|
+
const { memberNames, baseDir } = resolveBoundaryMemberContext();
|
|
157
|
+
const scan = scanGlobalBoundary({
|
|
158
|
+
vendors,
|
|
159
|
+
memberNames,
|
|
160
|
+
managedGlobalNames: managedGlobalNamesFrom(lastSync?.skills),
|
|
161
|
+
baseDir,
|
|
162
|
+
});
|
|
163
|
+
const disclosure = formatGlobalBoundaryDisclosure(scan);
|
|
164
|
+
// Two-space indent matches every other list line; the line itself
|
|
165
|
+
// is byte-identical to the session-hook disclosure.
|
|
166
|
+
if (disclosure) stdout.write(` ${disclosure}\n`);
|
|
167
|
+
} catch {
|
|
168
|
+
// Disclosure is best-effort on every surface.
|
|
169
|
+
}
|
|
125
170
|
}
|
|
126
171
|
|
|
127
172
|
// ── Per-skill augmentation ─────────────────────────────────────────────
|
package/src/commands/update.mjs
CHANGED
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
48
|
import { runSync } from "../lib/sync.mjs";
|
|
49
|
+
import { formatGlobalBoundaryDisclosure } from "../lib/global-boundary.mjs";
|
|
50
|
+
import { formatRepoComplianceSummary } from "../lib/foreign-content.mjs";
|
|
49
51
|
import {
|
|
50
52
|
resolveFlags,
|
|
51
53
|
effectiveVendors,
|
|
@@ -64,6 +66,14 @@ import {
|
|
|
64
66
|
* - 304 Not Modified → exit 0, NO output.
|
|
65
67
|
* - 200 with changes → exit 0, ONE line: `[SkillRepo] Library synced: N added, N updated, N removed.`
|
|
66
68
|
* - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
|
|
69
|
+
* - Global-boundary disclosure (#2495): when the sync's summary
|
|
70
|
+
* reports global skills that will also load in this session
|
|
71
|
+
* (`globalBoundary.total > 0`), ONE additional line prints on
|
|
72
|
+
* EVERY success path — after the sync line, or alone on the
|
|
73
|
+
* otherwise-silent 304/zero-delta/throttled paths. Plain stdout
|
|
74
|
+
* enters the session's model context, which is the point: the
|
|
75
|
+
* agent itself learns what extra skills are in play. Failure
|
|
76
|
+
* paths never disclose (the failure line stays the single line).
|
|
67
77
|
*
|
|
68
78
|
* The "exit 0 on all errors" contract is non-negotiable: a sync
|
|
69
79
|
* failure must NEVER block a Claude Code session start. Users on a
|
|
@@ -162,10 +172,63 @@ export async function runUpdate(argv, io = {}) {
|
|
|
162
172
|
const skipped = summary.skipped ?? 0;
|
|
163
173
|
const total =
|
|
164
174
|
summary.added + summary.updated + summary.removed + skipped;
|
|
175
|
+
// Global-boundary disclosure (#2495), computed BEFORE the silent
|
|
176
|
+
// branch below: the quiet 304/zero-delta/throttled session is
|
|
177
|
+
// the COMMON session, and it must still disclose — the line
|
|
178
|
+
// exists so the session's model context knows about the global
|
|
179
|
+
// skills loading alongside the synced set, not to report sync
|
|
180
|
+
// work. Success paths only; the catch below never reaches here.
|
|
181
|
+
// Formatting is its OWN failure domain (architect review r1): a
|
|
182
|
+
// formatter defect must degrade to "no line", never fall into
|
|
183
|
+
// the outer catch and report a successful sync as failed —
|
|
184
|
+
// "disclosure must never break a sync" applies to the printer
|
|
185
|
+
// exactly as it does to the scanner.
|
|
186
|
+
let disclosure = null;
|
|
187
|
+
try {
|
|
188
|
+
disclosure =
|
|
189
|
+
summary.globalBoundary && summary.globalBoundary.total > 0
|
|
190
|
+
? formatGlobalBoundaryDisclosure(summary.globalBoundary)
|
|
191
|
+
: null;
|
|
192
|
+
} catch {
|
|
193
|
+
// Degrade to no disclosure line.
|
|
194
|
+
}
|
|
195
|
+
// Repo compliance state (#2665, owner decision 2026-08-20): a
|
|
196
|
+
// skillset-declared repo that does not match its set says so in
|
|
197
|
+
// EVERY session, hook mode included — a state, never a
|
|
198
|
+
// warn-on-new event. Same failure domain as the disclosure: a
|
|
199
|
+
// formatter defect degrades to no line, never a failed sync.
|
|
200
|
+
let compliance = null;
|
|
201
|
+
try {
|
|
202
|
+
compliance = summary.compliance
|
|
203
|
+
? formatRepoComplianceSummary({ ...summary.compliance, hookMode: true })
|
|
204
|
+
: null;
|
|
205
|
+
} catch {
|
|
206
|
+
// Degrade to no compliance line.
|
|
207
|
+
}
|
|
208
|
+
const writeDisclosureLine = () => {
|
|
209
|
+
if (!disclosure) return;
|
|
210
|
+
try {
|
|
211
|
+
stdout.write(`${disclosure}\n`);
|
|
212
|
+
} catch {
|
|
213
|
+
// Same failure domain as the formatter: a write failure on
|
|
214
|
+
// the cosmetic line must not become a "Sync failed" report.
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const writeComplianceLine = () => {
|
|
218
|
+
if (!compliance) return;
|
|
219
|
+
try {
|
|
220
|
+
stdout.write(`${compliance}\n`);
|
|
221
|
+
} catch {
|
|
222
|
+
// Same failure domain as the disclosure write above.
|
|
223
|
+
}
|
|
224
|
+
};
|
|
165
225
|
if (summary.notModified || total === 0) {
|
|
166
226
|
// 304 Not Modified OR 200 with zero deltas — silent by
|
|
167
|
-
// contract
|
|
168
|
-
//
|
|
227
|
+
// contract (the boundary disclosure and the compliance state
|
|
228
|
+
// are the sanctioned exceptions). Users should not see
|
|
229
|
+
// "Syncing..." on every session for no visible value.
|
|
230
|
+
writeDisclosureLine();
|
|
231
|
+
writeComplianceLine();
|
|
169
232
|
return;
|
|
170
233
|
}
|
|
171
234
|
stdout.write(
|
|
@@ -173,6 +236,8 @@ export async function runUpdate(argv, io = {}) {
|
|
|
173
236
|
(skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
|
|
174
237
|
`.\n`,
|
|
175
238
|
);
|
|
239
|
+
writeDisclosureLine();
|
|
240
|
+
writeComplianceLine();
|
|
176
241
|
} catch (err) {
|
|
177
242
|
// The one-line failure message is the user's primary signal
|
|
178
243
|
// that something's wrong. Do not surface a stack trace — the
|
|
@@ -186,11 +186,25 @@ export function scanForeignContent({ vendors, global, managedSkills, baseDir })
|
|
|
186
186
|
* rules. The names stay on this machine — see the module docstring's
|
|
187
187
|
* privacy contract.
|
|
188
188
|
*
|
|
189
|
+
* Two copy branches (#2665): in a skillset-declared repo the finding is
|
|
190
|
+
* a COMPLIANCE statement — the consequence is named ("reported as not
|
|
191
|
+
* compliant") and the remediation is the governed path (remove, or have
|
|
192
|
+
* a skillset manager add the skill). `skillrepo push` is deliberately
|
|
193
|
+
* NOT offered there: pushing publishes the dir to the library but does
|
|
194
|
+
* not put it in the skillset, so it cannot restore compliance — and the
|
|
195
|
+
* developer may not hold publish rights at all. Undeclared repos keep
|
|
196
|
+
* the original library-framed push-or-remove copy.
|
|
197
|
+
*
|
|
189
198
|
* @param {ForeignScanResult} scan
|
|
199
|
+
* @param {object} [options]
|
|
200
|
+
* @param {string | null} [options.skillsetRef] - The declared skillset
|
|
201
|
+
* (`owner/name`, schema-validated upstream) when the repo is
|
|
202
|
+
* skillset-scoped; absent/null → library-framed copy.
|
|
190
203
|
* @returns {string[]}
|
|
191
204
|
*/
|
|
192
|
-
export function formatForeignWarnings(scan) {
|
|
205
|
+
export function formatForeignWarnings(scan, { skillsetRef } = {}) {
|
|
193
206
|
const lines = [];
|
|
207
|
+
const declared = typeof skillsetRef === "string" && skillsetRef.length > 0;
|
|
194
208
|
for (const rootResult of scan.roots) {
|
|
195
209
|
for (const dir of rootResult.foreignDirs) {
|
|
196
210
|
// Directory names come from readdirSync — filesystem-sourced and
|
|
@@ -200,6 +214,15 @@ export function formatForeignWarnings(scan) {
|
|
|
200
214
|
// server-path treatment in file-write.mjs (#2402 class).
|
|
201
215
|
const safeDir = escapeControlChars(dir);
|
|
202
216
|
const displayPath = `${rootResult.displayRoot}${safeDir}`;
|
|
217
|
+
if (declared) {
|
|
218
|
+
lines.push(
|
|
219
|
+
` warning: ${displayPath}/ is not part of this repo's skillset ` +
|
|
220
|
+
`(${skillsetRef}), but agents still load it from disk. Remove the ` +
|
|
221
|
+
`directory, or ask a skillset manager to add the skill. Until ` +
|
|
222
|
+
`then this repo is reported as not compliant.`,
|
|
223
|
+
);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
203
226
|
// The remediation must be a RUNNABLE command. A tilde-shortened
|
|
204
227
|
// display root ("~/.claude/skills/") breaks copy-paste — quotes
|
|
205
228
|
// suppress shell tilde expansion and Windows cmd never expands
|
|
@@ -217,6 +240,100 @@ export function formatForeignWarnings(scan) {
|
|
|
217
240
|
return lines;
|
|
218
241
|
}
|
|
219
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The repo compliance state line (#2665, owner decisions 2026-08-20):
|
|
245
|
+
* ONE sentence stating that the repo does not currently match its
|
|
246
|
+
* declared skillset and is reported as not compliant, listing the
|
|
247
|
+
* locally-observable causes as counts. Unlike the per-dir warnings
|
|
248
|
+
* above this is a STATE, not an event — callers print it on EVERY
|
|
249
|
+
* scoped sync while any cause persists (never gated on
|
|
250
|
+
* `.governance-seen`), including session-hook syncs, where it takes
|
|
251
|
+
* the `[SkillRepo]` prefix so the agent session sees it too (the
|
|
252
|
+
* #2495 disclosure precedent). Counts only — names stay in the
|
|
253
|
+
* per-dir warnings, which remain local-only and warn-on-new.
|
|
254
|
+
*
|
|
255
|
+
* Server-side causes a fresh sync cannot observe (behind-version or
|
|
256
|
+
* missing members on a repo that has not re-synced) surface on the
|
|
257
|
+
* dashboard instead — this line covers what THIS machine can see now.
|
|
258
|
+
*
|
|
259
|
+
* @param {object} input
|
|
260
|
+
* @param {string} input.skillsetRef - Declared skillset (`owner/name`).
|
|
261
|
+
* @param {number} [input.unmanagedCount] - Project-scope foreign dirs
|
|
262
|
+
* (the scan-derived count — matches the receipt's `unmanaged`
|
|
263
|
+
* violation category, deliberately NOT mixed with the
|
|
264
|
+
* member-name collisions below).
|
|
265
|
+
* @param {number} [input.shadowedCount] - Global copies shadowing members.
|
|
266
|
+
* @param {number} [input.globalBeyondCount] - Global-scope skills beyond
|
|
267
|
+
* the set (`global_library` + `global_foreign`).
|
|
268
|
+
* @param {number} [input.editRefusedCount] - Members whose LOCAL EDITS
|
|
269
|
+
* the sync refused to overwrite (`unwrittenReason: "modified"`
|
|
270
|
+
* only — the one bucket allowed to claim an edit).
|
|
271
|
+
* @param {number} [input.memberReplacedCount] - Members whose slot is
|
|
272
|
+
* occupied by hand-authored content the CLI never wrote
|
|
273
|
+
* (`unwrittenReason: "unmanaged"` — invisible to the foreign
|
|
274
|
+
* scan because the dir name IS a managed member name).
|
|
275
|
+
* @param {number} [input.notDeliveredCount] - Members not delivered as
|
|
276
|
+
* approved for server/payload reasons ("incomplete", "invalid")
|
|
277
|
+
* or unknown legacy reasons — never phrased as an edit.
|
|
278
|
+
* @param {boolean} [input.hookMode] - Session-hook formatting
|
|
279
|
+
* (`[SkillRepo]` prefix, single line for the hook UI).
|
|
280
|
+
* @returns {string | null} The line, or null when every count is zero.
|
|
281
|
+
*/
|
|
282
|
+
export function formatRepoComplianceSummary({
|
|
283
|
+
skillsetRef,
|
|
284
|
+
unmanagedCount = 0,
|
|
285
|
+
shadowedCount = 0,
|
|
286
|
+
globalBeyondCount = 0,
|
|
287
|
+
editRefusedCount = 0,
|
|
288
|
+
memberReplacedCount = 0,
|
|
289
|
+
notDeliveredCount = 0,
|
|
290
|
+
hookMode = false,
|
|
291
|
+
}) {
|
|
292
|
+
const parts = [];
|
|
293
|
+
if (unmanagedCount > 0) {
|
|
294
|
+
parts.push(
|
|
295
|
+
`${unmanagedCount} extra skill${unmanagedCount === 1 ? "" : "s"} in the project`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (shadowedCount > 0) {
|
|
299
|
+
parts.push(
|
|
300
|
+
`${shadowedCount} member${shadowedCount === 1 ? "" : "s"} shadowed by a global copy`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (globalBeyondCount > 0) {
|
|
304
|
+
parts.push(
|
|
305
|
+
`${globalBeyondCount} global skill${globalBeyondCount === 1 ? "" : "s"} beyond the set`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (memberReplacedCount > 0) {
|
|
309
|
+
parts.push(
|
|
310
|
+
`${memberReplacedCount} member${memberReplacedCount === 1 ? "" : "s"} replaced by unmanaged content`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (editRefusedCount > 0) {
|
|
314
|
+
parts.push(
|
|
315
|
+
`${editRefusedCount} member${editRefusedCount === 1 ? "" : "s"} edited locally`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (notDeliveredCount > 0) {
|
|
319
|
+
parts.push(
|
|
320
|
+
`${notDeliveredCount} member${notDeliveredCount === 1 ? "" : "s"} not delivered as approved`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (parts.length === 0) return null;
|
|
324
|
+
const causes = parts.join(", ");
|
|
325
|
+
if (hookMode) {
|
|
326
|
+
return (
|
|
327
|
+
`[SkillRepo] This repo does not match its skillset ${skillsetRef} ` +
|
|
328
|
+
`(${causes}) — it is reported as not compliant.`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return (
|
|
332
|
+
` warning: this repo does not match its skillset ${skillsetRef}: ` +
|
|
333
|
+
`${causes}. It is reported as not compliant to your organization.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
220
337
|
// ── Warn-on-new state (#2361 owner directive: no repeat warnings) ──────
|
|
221
338
|
//
|
|
222
339
|
// A warning that repeats unchanged findings on every sync trains users
|
|
@@ -245,11 +362,41 @@ export function formatForeignWarnings(scan) {
|
|
|
245
362
|
* @property {Record<string, boolean>} declarations - abs repo path →
|
|
246
363
|
* true when the gitignored-declaration warning was already
|
|
247
364
|
* shown and the declaration is still gitignored.
|
|
365
|
+
* @property {Record<string, Record<string, string[]>>} globalBoundary -
|
|
366
|
+
* abs repo path → (abs global root → dir names already warned
|
|
367
|
+
* about, sorted). The #2495 global-boundary counterpart to
|
|
368
|
+
* `roots`, keyed per REPO because the same global dir means
|
|
369
|
+
* different things in different repos (shadowing one repo's
|
|
370
|
+
* skillset member, plain foreign content elsewhere) — one
|
|
371
|
+
* repo consuming the warning must not silence it for another.
|
|
248
372
|
*/
|
|
249
373
|
|
|
250
374
|
/** @returns {GovernanceSeenState} */
|
|
251
375
|
function emptySeenState() {
|
|
252
|
-
return { schemaVersion: 1, roots: {}, declarations: {} };
|
|
376
|
+
return { schemaVersion: 1, roots: {}, declarations: {}, globalBoundary: {} };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Shape-check the `globalBoundary` field on read: an object of objects
|
|
381
|
+
* of arrays, or `{}` when anything about it is malformed — the same
|
|
382
|
+
* warn-everything degradation the other fields use, applied to the
|
|
383
|
+
* whole field (a partially-trusted structure isn't worth salvaging
|
|
384
|
+
* when the cost of a reset is one repeated warning).
|
|
385
|
+
*
|
|
386
|
+
* @param {unknown} value
|
|
387
|
+
* @returns {Record<string, Record<string, string[]>>}
|
|
388
|
+
*/
|
|
389
|
+
function coerceGlobalBoundary(value) {
|
|
390
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
391
|
+
for (const repoMap of Object.values(value)) {
|
|
392
|
+
if (!repoMap || typeof repoMap !== "object" || Array.isArray(repoMap)) {
|
|
393
|
+
return {};
|
|
394
|
+
}
|
|
395
|
+
for (const names of Object.values(repoMap)) {
|
|
396
|
+
if (!Array.isArray(names)) return {};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return /** @type {Record<string, Record<string, string[]>>} */ (value);
|
|
253
400
|
}
|
|
254
401
|
|
|
255
402
|
/** @returns {GovernanceSeenState} */
|
|
@@ -262,6 +409,11 @@ export function readGovernanceSeen() {
|
|
|
262
409
|
// at the current schema. A future v2 that must PRESERVE v1 data
|
|
263
410
|
// needs an explicit accept-in-place branch here, the way
|
|
264
411
|
// sync.mjs's readLastSync accepts its immediately-prior schema.
|
|
412
|
+
// `globalBoundary` (#2495) is an ADDITIVE key at schemaVersion 1,
|
|
413
|
+
// not a bump: a pre-#2495 CLI sharing this file simply drops the
|
|
414
|
+
// key on its next write, and the cost is one repeated boundary
|
|
415
|
+
// warning on the next post-#2495 interactive sync — documented,
|
|
416
|
+
// acceptable, and self-healing.
|
|
265
417
|
if (!parsed || typeof parsed !== "object" || parsed.schemaVersion !== 1) {
|
|
266
418
|
return emptySeenState();
|
|
267
419
|
}
|
|
@@ -277,6 +429,7 @@ export function readGovernanceSeen() {
|
|
|
277
429
|
!Array.isArray(parsed.declarations)
|
|
278
430
|
? parsed.declarations
|
|
279
431
|
: {},
|
|
432
|
+
globalBoundary: coerceGlobalBoundary(parsed.globalBoundary),
|
|
280
433
|
};
|
|
281
434
|
} catch {
|
|
282
435
|
return emptySeenState();
|
|
@@ -396,7 +549,110 @@ export function commitGovernanceSeen({ scan, repoKey, declarationIgnored }) {
|
|
|
396
549
|
} else {
|
|
397
550
|
delete declarations[repoKey];
|
|
398
551
|
}
|
|
399
|
-
writeGovernanceSeen({
|
|
552
|
+
writeGovernanceSeen({
|
|
553
|
+
schemaVersion: 1,
|
|
554
|
+
roots,
|
|
555
|
+
declarations,
|
|
556
|
+
// Pass-through from the FRESH read (#2495): this commit owns only
|
|
557
|
+
// `roots` + this repo's declaration flag; the boundary scan's keys
|
|
558
|
+
// belong to `commitGlobalBoundarySeen`, and dropping them here
|
|
559
|
+
// would re-warn every boundary finding after every project sync.
|
|
560
|
+
globalBoundary: fresh.globalBoundary,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Commit a global-boundary scan (#2495) into the seen-state file with
|
|
566
|
+
* the same NARROW merge discipline as `commitGovernanceSeen`: fresh
|
|
567
|
+
* read, rewrite ONLY `globalBoundary[repoKey]` (this repo's view), and
|
|
568
|
+
* pass `roots`/`declarations` — plus every OTHER repo's boundary key —
|
|
569
|
+
* through from the fresh read, so a concurrent sync elsewhere keeps
|
|
570
|
+
* its update even if this process writes last.
|
|
571
|
+
*
|
|
572
|
+
* Replace-not-merge per scanned root within the repo key: dirs that
|
|
573
|
+
* disappeared are pruned so a removed-then-reappearing dir warns
|
|
574
|
+
* again; a clean root deletes its key, and a repo whose every scanned
|
|
575
|
+
* root ended clean deletes its whole entry (state files should not
|
|
576
|
+
* accumulate empty husks).
|
|
577
|
+
*
|
|
578
|
+
* The RESIDUAL same-key race documented on `writeGovernanceSeen`
|
|
579
|
+
* applies here identically: two concurrent interactive syncs of ONE
|
|
580
|
+
* repo last-write-win on that repoKey, which can revive a pruned
|
|
581
|
+
* entry and suppress a re-warn — a repeated-or-suppressed warning,
|
|
582
|
+
* never a wrong receipt count (counts come from the live scan).
|
|
583
|
+
*
|
|
584
|
+
* @param {object} options
|
|
585
|
+
* @param {{ roots: { root: string, entries: { name: string }[] }[] }} options.scan
|
|
586
|
+
* The FULL boundary scan (everything currently on disk, not
|
|
587
|
+
* just the newly-warned entries).
|
|
588
|
+
* @param {string} options.repoKey - Normalized repo path.
|
|
589
|
+
*/
|
|
590
|
+
export function commitGlobalBoundarySeen({ scan, repoKey }) {
|
|
591
|
+
const fresh = readGovernanceSeen();
|
|
592
|
+
const globalBoundary = { ...fresh.globalBoundary };
|
|
593
|
+
const repoMap = { ...(globalBoundary[repoKey] ?? {}) };
|
|
594
|
+
for (const rootResult of scan?.roots ?? []) {
|
|
595
|
+
const names = (rootResult.entries ?? []).map((entry) => entry.name).sort();
|
|
596
|
+
if (names.length > 0) {
|
|
597
|
+
repoMap[rootResult.root] = names;
|
|
598
|
+
} else {
|
|
599
|
+
delete repoMap[rootResult.root];
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (Object.keys(repoMap).length === 0) {
|
|
603
|
+
delete globalBoundary[repoKey];
|
|
604
|
+
} else {
|
|
605
|
+
globalBoundary[repoKey] = repoMap;
|
|
606
|
+
}
|
|
607
|
+
writeGovernanceSeen({
|
|
608
|
+
schemaVersion: 1,
|
|
609
|
+
roots: fresh.roots,
|
|
610
|
+
declarations: fresh.declarations,
|
|
611
|
+
globalBoundary,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Select the boundary findings this repo has NOT been warned about
|
|
617
|
+
* yet. Pure — no IO; the `commitGlobalBoundarySeen` above does the
|
|
618
|
+
* bookkeeping. Mirrors `selectNewFindings`, keyed one level deeper
|
|
619
|
+
* (per repo, then per root).
|
|
620
|
+
*
|
|
621
|
+
* Gates ONLY the interactive warning lines: the disclosure line and
|
|
622
|
+
* the receipt categories always use the FULL scan — seen-state
|
|
623
|
+
* suppresses repetition for a human, never facts for the record.
|
|
624
|
+
*
|
|
625
|
+
* @param {import("./global-boundary.mjs").GlobalBoundaryScan} scan
|
|
626
|
+
* @param {GovernanceSeenState} seen
|
|
627
|
+
* @param {string} repoKey - Normalized repo path.
|
|
628
|
+
* @returns {{ roots: object[], counts: Record<string, number>, total: number }}
|
|
629
|
+
* Scan-shaped (roots/counts/total recomputed over the unseen
|
|
630
|
+
* entries). No `summary` — the disclosure surfaces never
|
|
631
|
+
* consume a filtered scan, so embedding one here would invite
|
|
632
|
+
* exactly the partial-disclosure bug the gating rule forbids.
|
|
633
|
+
*/
|
|
634
|
+
export function selectNewGlobalBoundaryFindings(scan, seen, repoKey) {
|
|
635
|
+
const repoMap =
|
|
636
|
+
seen?.globalBoundary && typeof seen.globalBoundary === "object"
|
|
637
|
+
? seen.globalBoundary[repoKey] ?? {}
|
|
638
|
+
: {};
|
|
639
|
+
const counts = {};
|
|
640
|
+
for (const key of Object.keys(scan?.counts ?? {})) counts[key] = 0;
|
|
641
|
+
const result = { roots: [], counts, total: 0 };
|
|
642
|
+
for (const rootResult of scan?.roots ?? []) {
|
|
643
|
+
const prior = new Set(
|
|
644
|
+
Array.isArray(repoMap[rootResult.root]) ? repoMap[rootResult.root] : [],
|
|
645
|
+
);
|
|
646
|
+
const freshEntries = (rootResult.entries ?? []).filter(
|
|
647
|
+
(entry) => !prior.has(entry.name),
|
|
648
|
+
);
|
|
649
|
+
result.roots.push({ ...rootResult, entries: freshEntries });
|
|
650
|
+
for (const entry of freshEntries) {
|
|
651
|
+
counts[entry.bucket] = (counts[entry.bucket] ?? 0) + 1;
|
|
652
|
+
result.total += 1;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return result;
|
|
400
656
|
}
|
|
401
657
|
|
|
402
658
|
/**
|