knosky 0.6.2 → 0.7.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/CHANGELOG.md +149 -93
- package/CREDITS.md +14 -14
- package/LICENSE.md +76 -76
- package/LIMITATIONS.md +33 -23
- package/PRIVACY.md +30 -30
- package/README.md +170 -117
- package/SECURITY.md +78 -46
- package/action/post-comment.mjs +94 -89
- package/action.yml +62 -62
- package/bin/knosky.mjs +279 -105
- package/core/CONTRACT.md +70 -70
- package/core/append-only-checkpoint.mjs +215 -0
- package/core/audit-writer.mjs +317 -0
- package/core/benchmark-results.mjs +225 -225
- package/core/bundle.mjs +178 -178
- package/core/churn.mjs +23 -23
- package/core/ci.mjs +268 -268
- package/core/comparison.mjs +189 -189
- package/core/config.mjs +189 -189
- package/core/constants.mjs +13 -13
- package/core/contract.mjs +123 -123
- package/core/cross-repo.mjs +111 -111
- package/core/decision-codes.mjs +92 -0
- package/core/destination.mjs +161 -161
- package/core/district-classification.mjs +111 -0
- package/core/doctor-scorecard.mjs +369 -0
- package/core/domain-store.mjs +347 -0
- package/core/edges.mjs +43 -43
- package/core/escalate.mjs +68 -68
- package/core/freshness.mjs +198 -194
- package/core/fs-indexer.mjs +218 -218
- package/core/key-store.mjs +348 -348
- package/core/layout.mjs +46 -46
- package/core/ledger.mjs +176 -141
- package/core/local-ipc-identity.mjs +500 -0
- package/core/lod.mjs +155 -155
- package/core/mode-b.mjs +410 -0
- package/core/multi-model-benchmark.mjs +405 -405
- package/core/net-lockdown.mjs +421 -0
- package/core/onboarding.mjs +223 -223
- package/core/operator-auth.mjs +317 -0
- package/core/overlays.mjs +45 -45
- package/core/policy-lattice.mjs +142 -0
- package/core/pr-comment.mjs +198 -198
- package/core/protocol-spec.mjs +460 -460
- package/core/provenance.mjs +320 -0
- package/core/retrieve.mjs +63 -63
- package/core/route.mjs +304 -304
- package/core/schema.mjs +275 -275
- package/core/signing-tiers.mjs +1265 -0
- package/core/swarm-bench.mjs +106 -0
- package/core/swarm-coordinator.mjs +867 -0
- package/core/trust-root-rekey.mjs +410 -0
- package/mcp/server.mjs +264 -108
- package/package.json +56 -46
- package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
- package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
- package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
- package/renderer/art/kenney/sheet_allCars.xml +545 -545
- package/renderer/build-rich.mjs +43 -43
- package/renderer/city.template.html +808 -824
- package/ssot/decision-codes.json +133 -0
- package/ssot/ladder-l0-l3.md +232 -0
- package/ssot/tool-menu.json +130 -0
package/core/bundle.mjs
CHANGED
|
@@ -1,178 +1,178 @@
|
|
|
1
|
-
// KnoSky bundle engine (KSV2-R4) — intent-manifest builder with fail-closed secret scan.
|
|
2
|
-
// Pure Node stdlib + internal imports only. ESM. No new deps.
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
|
-
import { readFileSync, realpathSync } from 'node:fs';
|
|
5
|
-
import { join, relative, isAbsolute, sep } from 'node:path';
|
|
6
|
-
import { findSecrets } from './contract.mjs';
|
|
7
|
-
import { makeIntentManifest, validateIntentManifest } from './schema.mjs';
|
|
8
|
-
import { extractLedgerSeq } from './freshness.mjs';
|
|
9
|
-
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// Internal helpers
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Return true when `ref` is a safe, repo-relative path (not absolute, no "..").
|
|
16
|
-
* @param {string|undefined} ref
|
|
17
|
-
* @returns {boolean}
|
|
18
|
-
*/
|
|
19
|
-
function isSafeRef(ref) {
|
|
20
|
-
if (!ref || typeof ref !== 'string') return false;
|
|
21
|
-
if (ref.startsWith('/')) return false;
|
|
22
|
-
// Windows drive: C:\ or C:/
|
|
23
|
-
if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
|
|
24
|
-
if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Resolve `ref` under `root` and read it — following symlinks via realpathSync,
|
|
30
|
-
* then verifying the REAL resolved path still lands inside `root` before the
|
|
31
|
-
* file is ever opened. `isSafeRef` above only validates the path STRING; it
|
|
32
|
-
* cannot see that a same-named file on disk is actually a symlink pointing
|
|
33
|
-
* outside root. This is the actual read boundary (belt-and-suspenders on top
|
|
34
|
-
* of fs-indexer's own symlink exclusion at index time — this is a separate
|
|
35
|
-
* read path with its own live filesystem access, so it re-checks independently).
|
|
36
|
-
*
|
|
37
|
-
* Never throws. Distinguishes "nothing to scan by design" (no root given —
|
|
38
|
-
* the caller explicitly opted out of content access) from "root given but the
|
|
39
|
-
* file could not be verified safe/readable" (fail-closed: caller must treat
|
|
40
|
-
* this as unscannable, NEVER as clean).
|
|
41
|
-
*
|
|
42
|
-
* @param {string|undefined} root
|
|
43
|
-
* @param {string} ref
|
|
44
|
-
* @returns {{ buf: Buffer } | { noRoot: true } | { unsafe: true }}
|
|
45
|
-
*/
|
|
46
|
-
function safeRead(root, ref) {
|
|
47
|
-
if (!root) return { noRoot: true };
|
|
48
|
-
const full = join(root, ref);
|
|
49
|
-
let real, rootReal;
|
|
50
|
-
try {
|
|
51
|
-
real = realpathSync(full);
|
|
52
|
-
rootReal = realpathSync(root);
|
|
53
|
-
} catch {
|
|
54
|
-
return { unsafe: true }; // missing / broken symlink / permission error — cannot verify
|
|
55
|
-
}
|
|
56
|
-
const rel = relative(rootReal, real);
|
|
57
|
-
if (rel === sep || rel.startsWith('..' + sep) || rel === '..' || isAbsolute(rel)) {
|
|
58
|
-
return { unsafe: true }; // real path escapes root (symlink pointed outside) — refuse
|
|
59
|
-
}
|
|
60
|
-
try {
|
|
61
|
-
return { buf: readFileSync(real) };
|
|
62
|
-
} catch {
|
|
63
|
-
return { unsafe: true };
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Compute the hex SHA-256 of a file. Returns "" when unreadable/unsafe/no-root.
|
|
69
|
-
* @param {string|undefined} root Repo root directory, or undefined.
|
|
70
|
-
* @param {string} ref Repo-relative path.
|
|
71
|
-
* @returns {string}
|
|
72
|
-
*/
|
|
73
|
-
function sha256OfFile(root, ref) {
|
|
74
|
-
const r = safeRead(root, ref);
|
|
75
|
-
if (!r.buf) return '';
|
|
76
|
-
return createHash('sha256').update(r.buf).digest('hex');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
// kcBundle — main export
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Build an intent-manifest for the given node ids.
|
|
85
|
-
*
|
|
86
|
-
* @param {object} ctx — retrieve context {city:{nodes}, byId:Map}
|
|
87
|
-
* @param {string[]} ids — node ids to include
|
|
88
|
-
* @param {object} [opts]
|
|
89
|
-
* @param {string} [opts.root] — repo root; used to read files for sha256 + secret scan
|
|
90
|
-
* @param {string|null} [opts.expiry] — ISO-8601 expiry timestamp or null
|
|
91
|
-
* @returns {object} intent-manifest (passes validateIntentManifest)
|
|
92
|
-
*/
|
|
93
|
-
export function kcBundle(ctx, ids, { root, expiry = null } = {}) {
|
|
94
|
-
// Normalise ids to an array of strings
|
|
95
|
-
const idList = Array.isArray(ids) ? ids.map(String) : [];
|
|
96
|
-
|
|
97
|
-
// Build the set of included ids (only those with safe refs)
|
|
98
|
-
const includedSet = new Set();
|
|
99
|
-
const refByid = new Map(); // id -> safe ref string
|
|
100
|
-
|
|
101
|
-
for (const id of idList) {
|
|
102
|
-
const node = ctx.byId.get(id);
|
|
103
|
-
if (!node) continue; // missing node — skip
|
|
104
|
-
const ref = node.provenance && node.provenance.ref;
|
|
105
|
-
if (!isSafeRef(ref)) continue; // absolute or ".." — skip
|
|
106
|
-
includedSet.add(id);
|
|
107
|
-
refByid.set(id, ref);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Build paths[] with sha256
|
|
111
|
-
const paths = [];
|
|
112
|
-
for (const id of idList) {
|
|
113
|
-
if (!includedSet.has(id)) continue;
|
|
114
|
-
const ref = refByid.get(id);
|
|
115
|
-
const sha256 = sha256OfFile(root, ref);
|
|
116
|
-
paths.push({ path: ref, sha256 });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Build edges[] — out-edges within the bundled set
|
|
120
|
-
const edges = [];
|
|
121
|
-
for (const id of idList) {
|
|
122
|
-
if (!includedSet.has(id)) continue;
|
|
123
|
-
const node = ctx.byId.get(id);
|
|
124
|
-
const links = Array.isArray(node.links) ? node.links : [];
|
|
125
|
-
for (const target of links) {
|
|
126
|
-
if (includedSet.has(target)) {
|
|
127
|
-
edges.push({ from: id, to: target });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Secret scan — FAIL-CLOSED. Three outcomes per included file:
|
|
133
|
-
// noRoot → caller opted out of content access entirely; nothing to scan by
|
|
134
|
-
// design (sha256 is also "" in this mode — a deliberate no-read mode,
|
|
135
|
-
// not a failure).
|
|
136
|
-
// unsafe → root WAS given but the file could not be verified safe-and-readable
|
|
137
|
-
// (missing, permission error, or a symlink resolving outside root).
|
|
138
|
-
// We can no longer prove it's secret-free, so we must NOT call it
|
|
139
|
-
// clean — treat it as blocked (the previous behavior silently
|
|
140
|
-
// skipped these and returned "clean", a fail-OPEN bug).
|
|
141
|
-
// text → scanned normally.
|
|
142
|
-
let totalMatches = 0;
|
|
143
|
-
let blocked = false;
|
|
144
|
-
let noRootMode = false;
|
|
145
|
-
|
|
146
|
-
for (const id of idList) {
|
|
147
|
-
if (!includedSet.has(id)) continue;
|
|
148
|
-
const ref = refByid.get(id);
|
|
149
|
-
const r = safeRead(root, ref);
|
|
150
|
-
if (r.noRoot) { noRootMode = true; continue; }
|
|
151
|
-
if (r.unsafe) { blocked = true; continue; }
|
|
152
|
-
const hits = findSecrets(r.buf.toString('utf8'));
|
|
153
|
-
if (hits.length > 0) {
|
|
154
|
-
blocked = true;
|
|
155
|
-
for (const [, count] of hits) totalMatches += count;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
void noRootMode; // no-root is intentionally a no-op above; named for readability
|
|
159
|
-
|
|
160
|
-
const secret_scan = blocked
|
|
161
|
-
? { status: 'blocked', count: totalMatches }
|
|
162
|
-
: { status: 'clean', count: 0 };
|
|
163
|
-
|
|
164
|
-
// Ledger-anchored freshness (SAT-444): propagate the monotone commit count
|
|
165
|
-
// from the city envelope into the manifest so consumers can apply the V13
|
|
166
|
-
// high-water-mark guard without needing the original city object.
|
|
167
|
-
const ledger_seq = extractLedgerSeq(ctx.city);
|
|
168
|
-
|
|
169
|
-
const manifest = makeIntentManifest({ paths, edges, expiry, secret_scan, ledger_seq });
|
|
170
|
-
|
|
171
|
-
// Invariant: manifest MUST pass validateIntentManifest
|
|
172
|
-
const validation = validateIntentManifest(manifest);
|
|
173
|
-
if (!validation.ok) {
|
|
174
|
-
throw new Error('kcBundle produced an invalid intent-manifest: ' + validation.errors.join('; '));
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return manifest;
|
|
178
|
-
}
|
|
1
|
+
// KnoSky bundle engine (KSV2-R4) — intent-manifest builder with fail-closed secret scan.
|
|
2
|
+
// Pure Node stdlib + internal imports only. ESM. No new deps.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { join, relative, isAbsolute, sep } from 'node:path';
|
|
6
|
+
import { findSecrets } from './contract.mjs';
|
|
7
|
+
import { makeIntentManifest, validateIntentManifest } from './schema.mjs';
|
|
8
|
+
import { extractLedgerSeq } from './freshness.mjs';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Internal helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return true when `ref` is a safe, repo-relative path (not absolute, no "..").
|
|
16
|
+
* @param {string|undefined} ref
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function isSafeRef(ref) {
|
|
20
|
+
if (!ref || typeof ref !== 'string') return false;
|
|
21
|
+
if (ref.startsWith('/')) return false;
|
|
22
|
+
// Windows drive: C:\ or C:/
|
|
23
|
+
if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
|
|
24
|
+
if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve `ref` under `root` and read it — following symlinks via realpathSync,
|
|
30
|
+
* then verifying the REAL resolved path still lands inside `root` before the
|
|
31
|
+
* file is ever opened. `isSafeRef` above only validates the path STRING; it
|
|
32
|
+
* cannot see that a same-named file on disk is actually a symlink pointing
|
|
33
|
+
* outside root. This is the actual read boundary (belt-and-suspenders on top
|
|
34
|
+
* of fs-indexer's own symlink exclusion at index time — this is a separate
|
|
35
|
+
* read path with its own live filesystem access, so it re-checks independently).
|
|
36
|
+
*
|
|
37
|
+
* Never throws. Distinguishes "nothing to scan by design" (no root given —
|
|
38
|
+
* the caller explicitly opted out of content access) from "root given but the
|
|
39
|
+
* file could not be verified safe/readable" (fail-closed: caller must treat
|
|
40
|
+
* this as unscannable, NEVER as clean).
|
|
41
|
+
*
|
|
42
|
+
* @param {string|undefined} root
|
|
43
|
+
* @param {string} ref
|
|
44
|
+
* @returns {{ buf: Buffer } | { noRoot: true } | { unsafe: true }}
|
|
45
|
+
*/
|
|
46
|
+
function safeRead(root, ref) {
|
|
47
|
+
if (!root) return { noRoot: true };
|
|
48
|
+
const full = join(root, ref);
|
|
49
|
+
let real, rootReal;
|
|
50
|
+
try {
|
|
51
|
+
real = realpathSync(full);
|
|
52
|
+
rootReal = realpathSync(root);
|
|
53
|
+
} catch {
|
|
54
|
+
return { unsafe: true }; // missing / broken symlink / permission error — cannot verify
|
|
55
|
+
}
|
|
56
|
+
const rel = relative(rootReal, real);
|
|
57
|
+
if (rel === sep || rel.startsWith('..' + sep) || rel === '..' || isAbsolute(rel)) {
|
|
58
|
+
return { unsafe: true }; // real path escapes root (symlink pointed outside) — refuse
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return { buf: readFileSync(real) };
|
|
62
|
+
} catch {
|
|
63
|
+
return { unsafe: true };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compute the hex SHA-256 of a file. Returns "" when unreadable/unsafe/no-root.
|
|
69
|
+
* @param {string|undefined} root Repo root directory, or undefined.
|
|
70
|
+
* @param {string} ref Repo-relative path.
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function sha256OfFile(root, ref) {
|
|
74
|
+
const r = safeRead(root, ref);
|
|
75
|
+
if (!r.buf) return '';
|
|
76
|
+
return createHash('sha256').update(r.buf).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// kcBundle — main export
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build an intent-manifest for the given node ids.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} ctx — retrieve context {city:{nodes}, byId:Map}
|
|
87
|
+
* @param {string[]} ids — node ids to include
|
|
88
|
+
* @param {object} [opts]
|
|
89
|
+
* @param {string} [opts.root] — repo root; used to read files for sha256 + secret scan
|
|
90
|
+
* @param {string|null} [opts.expiry] — ISO-8601 expiry timestamp or null
|
|
91
|
+
* @returns {object} intent-manifest (passes validateIntentManifest)
|
|
92
|
+
*/
|
|
93
|
+
export function kcBundle(ctx, ids, { root, expiry = null } = {}) {
|
|
94
|
+
// Normalise ids to an array of strings
|
|
95
|
+
const idList = Array.isArray(ids) ? ids.map(String) : [];
|
|
96
|
+
|
|
97
|
+
// Build the set of included ids (only those with safe refs)
|
|
98
|
+
const includedSet = new Set();
|
|
99
|
+
const refByid = new Map(); // id -> safe ref string
|
|
100
|
+
|
|
101
|
+
for (const id of idList) {
|
|
102
|
+
const node = ctx.byId.get(id);
|
|
103
|
+
if (!node) continue; // missing node — skip
|
|
104
|
+
const ref = node.provenance && node.provenance.ref;
|
|
105
|
+
if (!isSafeRef(ref)) continue; // absolute or ".." — skip
|
|
106
|
+
includedSet.add(id);
|
|
107
|
+
refByid.set(id, ref);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Build paths[] with sha256
|
|
111
|
+
const paths = [];
|
|
112
|
+
for (const id of idList) {
|
|
113
|
+
if (!includedSet.has(id)) continue;
|
|
114
|
+
const ref = refByid.get(id);
|
|
115
|
+
const sha256 = sha256OfFile(root, ref);
|
|
116
|
+
paths.push({ path: ref, sha256 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Build edges[] — out-edges within the bundled set
|
|
120
|
+
const edges = [];
|
|
121
|
+
for (const id of idList) {
|
|
122
|
+
if (!includedSet.has(id)) continue;
|
|
123
|
+
const node = ctx.byId.get(id);
|
|
124
|
+
const links = Array.isArray(node.links) ? node.links : [];
|
|
125
|
+
for (const target of links) {
|
|
126
|
+
if (includedSet.has(target)) {
|
|
127
|
+
edges.push({ from: id, to: target });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Secret scan — FAIL-CLOSED. Three outcomes per included file:
|
|
133
|
+
// noRoot → caller opted out of content access entirely; nothing to scan by
|
|
134
|
+
// design (sha256 is also "" in this mode — a deliberate no-read mode,
|
|
135
|
+
// not a failure).
|
|
136
|
+
// unsafe → root WAS given but the file could not be verified safe-and-readable
|
|
137
|
+
// (missing, permission error, or a symlink resolving outside root).
|
|
138
|
+
// We can no longer prove it's secret-free, so we must NOT call it
|
|
139
|
+
// clean — treat it as blocked (the previous behavior silently
|
|
140
|
+
// skipped these and returned "clean", a fail-OPEN bug).
|
|
141
|
+
// text → scanned normally.
|
|
142
|
+
let totalMatches = 0;
|
|
143
|
+
let blocked = false;
|
|
144
|
+
let noRootMode = false;
|
|
145
|
+
|
|
146
|
+
for (const id of idList) {
|
|
147
|
+
if (!includedSet.has(id)) continue;
|
|
148
|
+
const ref = refByid.get(id);
|
|
149
|
+
const r = safeRead(root, ref);
|
|
150
|
+
if (r.noRoot) { noRootMode = true; continue; }
|
|
151
|
+
if (r.unsafe) { blocked = true; continue; }
|
|
152
|
+
const hits = findSecrets(r.buf.toString('utf8'));
|
|
153
|
+
if (hits.length > 0) {
|
|
154
|
+
blocked = true;
|
|
155
|
+
for (const [, count] of hits) totalMatches += count;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
void noRootMode; // no-root is intentionally a no-op above; named for readability
|
|
159
|
+
|
|
160
|
+
const secret_scan = blocked
|
|
161
|
+
? { status: 'blocked', count: totalMatches }
|
|
162
|
+
: { status: 'clean', count: 0 };
|
|
163
|
+
|
|
164
|
+
// Ledger-anchored freshness (SAT-444): propagate the monotone commit count
|
|
165
|
+
// from the city envelope into the manifest so consumers can apply the V13
|
|
166
|
+
// high-water-mark guard without needing the original city object.
|
|
167
|
+
const ledger_seq = extractLedgerSeq(ctx.city);
|
|
168
|
+
|
|
169
|
+
const manifest = makeIntentManifest({ paths, edges, expiry, secret_scan, ledger_seq });
|
|
170
|
+
|
|
171
|
+
// Invariant: manifest MUST pass validateIntentManifest
|
|
172
|
+
const validation = validateIntentManifest(manifest);
|
|
173
|
+
if (!validation.ok) {
|
|
174
|
+
throw new Error('kcBundle produced an invalid intent-manifest: ' + validation.errors.join('; '));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return manifest;
|
|
178
|
+
}
|
package/core/churn.mjs
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
// Git churn (D-155): per-file commit count (windowed) + last-commit timestamp ONLY.
|
|
2
|
-
// No commit messages, diffs, hunks, authors, or line-level churn retained.
|
|
3
|
-
// execFileSync + an args array (not execSync + a shell string) — no argument here is
|
|
4
|
-
// externally controlled today, but this matches the args-array-only discipline used by
|
|
5
|
-
// every other git invocation in this codebase (ci.mjs) and removes the shell entirely,
|
|
6
|
-
// so a future edit that adds a dynamic path/ref here can't reintroduce an injection class.
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
|
-
|
|
9
|
-
export function gitChurn(root) {
|
|
10
|
-
const counts = {}, last = {};
|
|
11
|
-
try {
|
|
12
|
-
const out = execFileSync('git', ['log', '--since=90.days.ago', '--name-only', '--pretty=format:%ct', '--', '.'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
|
|
13
|
-
let ts = 0;
|
|
14
|
-
for (const raw of out.split(/\r?\n/)) {
|
|
15
|
-
const line = raw.trim();
|
|
16
|
-
if (!line) continue;
|
|
17
|
-
if (/^\d+$/.test(line)) { ts = parseInt(line, 10); continue; }
|
|
18
|
-
const rel = line.replace(/\\/g, '/');
|
|
19
|
-
counts[rel] = (counts[rel] || 0) + 1;
|
|
20
|
-
if (!last[rel] || ts > last[rel]) last[rel] = ts;
|
|
21
|
-
}
|
|
22
|
-
} catch { /* no git / no history -> empty */ }
|
|
23
|
-
return { counts, last };
|
|
1
|
+
// Git churn (D-155): per-file commit count (windowed) + last-commit timestamp ONLY.
|
|
2
|
+
// No commit messages, diffs, hunks, authors, or line-level churn retained.
|
|
3
|
+
// execFileSync + an args array (not execSync + a shell string) — no argument here is
|
|
4
|
+
// externally controlled today, but this matches the args-array-only discipline used by
|
|
5
|
+
// every other git invocation in this codebase (ci.mjs) and removes the shell entirely,
|
|
6
|
+
// so a future edit that adds a dynamic path/ref here can't reintroduce an injection class.
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export function gitChurn(root) {
|
|
10
|
+
const counts = {}, last = {};
|
|
11
|
+
try {
|
|
12
|
+
const out = execFileSync('git', ['log', '--since=90.days.ago', '--name-only', '--pretty=format:%ct', '--', '.'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
|
|
13
|
+
let ts = 0;
|
|
14
|
+
for (const raw of out.split(/\r?\n/)) {
|
|
15
|
+
const line = raw.trim();
|
|
16
|
+
if (!line) continue;
|
|
17
|
+
if (/^\d+$/.test(line)) { ts = parseInt(line, 10); continue; }
|
|
18
|
+
const rel = line.replace(/\\/g, '/');
|
|
19
|
+
counts[rel] = (counts[rel] || 0) + 1;
|
|
20
|
+
if (!last[rel] || ts > last[rel]) last[rel] = ts;
|
|
21
|
+
}
|
|
22
|
+
} catch { /* no git / no history -> empty */ }
|
|
23
|
+
return { counts, last };
|
|
24
24
|
}
|