mustflow 2.22.17 → 2.22.46
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/dist/cli/commands/dashboard.js +51 -4
- package/dist/cli/commands/explain.js +3 -2
- package/dist/cli/commands/help.js +0 -1
- package/dist/cli/commands/run.js +41 -4
- package/dist/cli/i18n/en.js +2 -0
- package/dist/cli/i18n/es.js +2 -0
- package/dist/cli/i18n/fr.js +2 -0
- package/dist/cli/i18n/hi.js +2 -0
- package/dist/cli/i18n/ko.js +2 -0
- package/dist/cli/i18n/zh.js +2 -0
- package/dist/cli/lib/cli-output.js +1 -1
- package/dist/cli/lib/dashboard-html/client-script.js +9 -0
- package/dist/cli/lib/dashboard-html/styles.js +48 -1
- package/dist/cli/lib/doc-review-ledger.js +1 -1
- package/dist/cli/lib/local-index/index.js +324 -298
- package/dist/cli/lib/repo-map.js +19 -5
- package/dist/cli/lib/validation/index.js +6 -2
- package/dist/core/active-run-locks.js +36 -8
- package/dist/core/atomic-state-write.js +5 -20
- package/dist/core/change-verification.js +18 -2
- package/dist/core/contract-lint.js +3 -3
- package/dist/core/repeated-failure.js +1 -1
- package/dist/core/run-write-drift.js +30 -17
- package/dist/core/safe-filesystem.js +54 -5
- package/dist/core/skill-route-explanation.js +2 -1
- package/dist/core/source-anchors.js +7 -3
- package/dist/core/validation-ratchet.js +61 -18
- package/dist/core/verification-decision-graph.js +8 -1
- package/package.json +1 -1
- package/templates/default/i18n.toml +139 -1
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +24 -1
- package/templates/default/locales/en/.mustflow/skills/api-contract-change/SKILL.md +212 -0
- package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +184 -0
- package/templates/default/locales/en/.mustflow/skills/auth-permission-change/SKILL.md +194 -0
- package/templates/default/locales/en/.mustflow/skills/config-env-change/SKILL.md +189 -0
- package/templates/default/locales/en/.mustflow/skills/css-code-change/SKILL.md +199 -0
- package/templates/default/locales/en/.mustflow/skills/dart-code-change/SKILL.md +179 -0
- package/templates/default/locales/en/.mustflow/skills/database-migration-change/SKILL.md +178 -0
- package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +151 -0
- package/templates/default/locales/en/.mustflow/skills/elysia-code-change/SKILL.md +115 -0
- package/templates/default/locales/en/.mustflow/skills/file-path-cross-platform-change/SKILL.md +147 -0
- package/templates/default/locales/en/.mustflow/skills/flutter-code-change/SKILL.md +116 -0
- package/templates/default/locales/en/.mustflow/skills/go-code-change/SKILL.md +156 -0
- package/templates/default/locales/en/.mustflow/skills/hono-code-change/SKILL.md +117 -0
- package/templates/default/locales/en/.mustflow/skills/html-code-change/SKILL.md +173 -0
- package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +149 -0
- package/templates/default/locales/en/.mustflow/skills/python-code-change/SKILL.md +154 -0
- package/templates/default/locales/en/.mustflow/skills/release-publish-change/SKILL.md +172 -0
- package/templates/default/locales/en/.mustflow/skills/routes.toml +138 -0
- package/templates/default/locales/en/.mustflow/skills/rust-code-change/SKILL.md +154 -0
- package/templates/default/locales/en/.mustflow/skills/svelte-code-change/SKILL.md +186 -0
- package/templates/default/locales/en/.mustflow/skills/tailwind-code-change/SKILL.md +164 -0
- package/templates/default/locales/en/.mustflow/skills/tauri-code-change/SKILL.md +185 -0
- package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +184 -0
- package/templates/default/locales/en/.mustflow/skills/unocss-code-change/SKILL.md +186 -0
- package/templates/default/manifest.toml +158 -1
package/dist/cli/lib/repo-map.js
CHANGED
|
@@ -69,6 +69,8 @@ const MACHINE_CONTRACT_ANCHOR_FILES = [
|
|
|
69
69
|
'schema.graphql',
|
|
70
70
|
'schema.prisma',
|
|
71
71
|
];
|
|
72
|
+
const ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILE_SET = new Set(ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILES);
|
|
73
|
+
const MACHINE_CONTRACT_ANCHOR_FILE_SET = new Set(MACHINE_CONTRACT_ANCHOR_FILES);
|
|
72
74
|
const DEFAULT_NESTED_ANCHOR_FILES = [
|
|
73
75
|
'AGENTS.md',
|
|
74
76
|
'REPO_MAP.md',
|
|
@@ -386,7 +388,13 @@ function renderDirectoryAnchors(anchors) {
|
|
|
386
388
|
const grouped = new Map();
|
|
387
389
|
for (const anchor of anchors) {
|
|
388
390
|
const directory = getDirectoryName(anchor.relativePath);
|
|
389
|
-
|
|
391
|
+
const group = grouped.get(directory);
|
|
392
|
+
if (group) {
|
|
393
|
+
group.push(anchor);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
grouped.set(directory, [anchor]);
|
|
397
|
+
}
|
|
390
398
|
}
|
|
391
399
|
for (const directory of Array.from(grouped.keys()).sort((left, right) => {
|
|
392
400
|
if (left === '/') {
|
|
@@ -469,13 +477,13 @@ function collectNestedRepository(projectRoot, repositoryPath, anchorFiles) {
|
|
|
469
477
|
.filter((anchorFile) => EDITING_POLICY_ANCHORS.has(anchorFile) && existingAnchors.has(anchorFile))
|
|
470
478
|
.map((anchorFile) => `${relativeRoot}${anchorFile}`);
|
|
471
479
|
const rootDocuments = anchorFiles
|
|
472
|
-
.filter((anchorFile) =>
|
|
480
|
+
.filter((anchorFile) => ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILE_SET.has(anchorFile) && existingAnchors.has(anchorFile))
|
|
473
481
|
.map((anchorFile) => ({
|
|
474
482
|
label: NESTED_ROOT_DOC_LABELS.get(anchorFile) ?? 'root document',
|
|
475
483
|
relativePath: `${relativeRoot}${anchorFile}`,
|
|
476
484
|
}));
|
|
477
485
|
const machineContracts = anchorFiles
|
|
478
|
-
.filter((anchorFile) =>
|
|
486
|
+
.filter((anchorFile) => MACHINE_CONTRACT_ANCHOR_FILE_SET.has(anchorFile) && existingAnchors.has(anchorFile))
|
|
479
487
|
.map((anchorFile) => `${relativeRoot}${anchorFile}`);
|
|
480
488
|
const mustflowConfig = resolveAnchor('.mustflow/config/mustflow.toml');
|
|
481
489
|
const commandContract = resolveAnchor('.mustflow/config/commands.toml');
|
|
@@ -502,9 +510,15 @@ function discoverNestedRepositories(projectRoot, mapConfig, workspaceConfig) {
|
|
|
502
510
|
const repositories = [];
|
|
503
511
|
const seenRepositoryPaths = new Set();
|
|
504
512
|
const seenDirectoryPaths = new Set();
|
|
505
|
-
|
|
513
|
+
let projectRootRealPath;
|
|
514
|
+
try {
|
|
515
|
+
projectRootRealPath = realpathSync(projectRoot);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return [];
|
|
519
|
+
}
|
|
506
520
|
function visit(directoryTarget, depth) {
|
|
507
|
-
if (repositories.length >= workspaceConfig.maxRepositories || depth
|
|
521
|
+
if (repositories.length >= workspaceConfig.maxRepositories || depth >= workspaceConfig.maxDepth) {
|
|
508
522
|
return;
|
|
509
523
|
}
|
|
510
524
|
if (seenDirectoryPaths.has(directoryTarget.realPath)) {
|
|
@@ -474,11 +474,15 @@ function validateSkills(projectRoot, issues) {
|
|
|
474
474
|
function readSkillSectionIds(content) {
|
|
475
475
|
return new Set([...content.matchAll(SKILL_SECTION_MARKER_PATTERN)].map((match) => match[1]));
|
|
476
476
|
}
|
|
477
|
+
function findFrontmatterEnd(content) {
|
|
478
|
+
const match = /\n---(?:\r?\n|$)/u.exec(content.slice(3));
|
|
479
|
+
return match ? 3 + match.index : -1;
|
|
480
|
+
}
|
|
477
481
|
function parseSimpleFrontmatter(content) {
|
|
478
482
|
if (!content.startsWith('---')) {
|
|
479
483
|
return {};
|
|
480
484
|
}
|
|
481
|
-
const end = content
|
|
485
|
+
const end = findFrontmatterEnd(content);
|
|
482
486
|
if (end === -1) {
|
|
483
487
|
return {};
|
|
484
488
|
}
|
|
@@ -510,7 +514,7 @@ function readFrontmatterLines(content) {
|
|
|
510
514
|
if (!content.startsWith('---')) {
|
|
511
515
|
return [];
|
|
512
516
|
}
|
|
513
|
-
const end = content
|
|
517
|
+
const end = findFrontmatterEnd(content);
|
|
514
518
|
if (end === -1) {
|
|
515
519
|
return [];
|
|
516
520
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { commandEffectsConflict, normalizeCommandEffects, } from './command-effects.js';
|
|
5
5
|
const ACTIVE_LOCK_SCHEMA_VERSION = '1';
|
|
@@ -8,8 +8,9 @@ const LOCK_ROOT_RELATIVE_PATH = '.mustflow/state/locks';
|
|
|
8
8
|
const LOCK_MUTEX_STALE_MS = 30_000;
|
|
9
9
|
const LOCK_MUTEX_WAIT_MS = 1_000;
|
|
10
10
|
const LOCK_MUTEX_SLEEP_MS = 25;
|
|
11
|
+
const LOCK_MUTEX_SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
11
12
|
function sleep(milliseconds) {
|
|
12
|
-
Atomics.wait(
|
|
13
|
+
Atomics.wait(LOCK_MUTEX_SLEEP_BUFFER, 0, 0, milliseconds);
|
|
13
14
|
}
|
|
14
15
|
function sha256(value) {
|
|
15
16
|
return createHash('sha256').update(value).digest('hex');
|
|
@@ -194,20 +195,38 @@ function createRecord(projectRoot, intentName, effects, commandHash) {
|
|
|
194
195
|
function acquireMutex(projectRoot) {
|
|
195
196
|
const root = activeLockRoot(projectRoot);
|
|
196
197
|
const mutex = activeLockMutexDirectory(projectRoot);
|
|
198
|
+
const ownerPath = path.join(mutex, 'owner.json');
|
|
199
|
+
const ownerToken = sha256(`${process.pid}:${Date.now()}:${process.hrtime.bigint()}`);
|
|
197
200
|
mkdirSync(root, { recursive: true });
|
|
198
201
|
let startedAt = Date.now();
|
|
199
202
|
while (true) {
|
|
200
203
|
try {
|
|
201
204
|
mkdirSync(mutex);
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
const ownerRecord = { pid: process.pid, started_at: new Date().toISOString(), token: ownerToken };
|
|
206
|
+
try {
|
|
207
|
+
writeFileSync(ownerPath, JSON.stringify(ownerRecord, null, 2));
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
rmSync(mutex, { recursive: true, force: true });
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
return () => {
|
|
214
|
+
try {
|
|
215
|
+
const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
|
|
216
|
+
if (Number(owner.pid) === ownerRecord.pid && owner.token === ownerRecord.token) {
|
|
217
|
+
rmSync(mutex, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// A missing or replaced owner file means this process no longer owns the mutex.
|
|
222
|
+
}
|
|
223
|
+
};
|
|
204
224
|
}
|
|
205
225
|
catch (error) {
|
|
206
226
|
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST') {
|
|
207
227
|
throw error;
|
|
208
228
|
}
|
|
209
229
|
if (Date.now() - startedAt > LOCK_MUTEX_WAIT_MS) {
|
|
210
|
-
const ownerPath = path.join(mutex, 'owner.json');
|
|
211
230
|
try {
|
|
212
231
|
const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
|
|
213
232
|
const ownerPid = Number(owner.pid);
|
|
@@ -220,9 +239,18 @@ function acquireMutex(projectRoot) {
|
|
|
220
239
|
}
|
|
221
240
|
}
|
|
222
241
|
catch {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
242
|
+
try {
|
|
243
|
+
const mutexStat = statSync(mutex);
|
|
244
|
+
if (Date.now() - mutexStat.mtimeMs > LOCK_MUTEX_STALE_MS) {
|
|
245
|
+
rmSync(mutex, { recursive: true, force: true });
|
|
246
|
+
startedAt = Date.now();
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
startedAt = Date.now();
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
226
254
|
}
|
|
227
255
|
throw new Error('active_run_lock_mutex_busy');
|
|
228
256
|
}
|
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { mkdirSync
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
const suffix = `${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`;
|
|
6
|
-
return path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${suffix}.tmp`);
|
|
7
|
-
}
|
|
4
|
+
import { writeUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
|
|
8
5
|
export function createStateRunId(prefix) {
|
|
9
6
|
const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
|
|
10
7
|
return `${prefix}-${timestamp}-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
11
8
|
}
|
|
12
9
|
export function atomicWriteTextFile(targetPath, content) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx' });
|
|
17
|
-
renameSync(temporaryPath, targetPath);
|
|
18
|
-
}
|
|
19
|
-
catch (error) {
|
|
20
|
-
try {
|
|
21
|
-
unlinkSync(temporaryPath);
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// Best-effort cleanup for a temporary file that may not have been created.
|
|
25
|
-
}
|
|
26
|
-
throw error;
|
|
27
|
-
}
|
|
10
|
+
const targetDirectory = path.dirname(targetPath);
|
|
11
|
+
mkdirSync(targetDirectory, { recursive: true });
|
|
12
|
+
writeUtf8FileInsideWithoutSymlinks(targetDirectory, targetPath, content);
|
|
28
13
|
}
|
|
29
14
|
export function atomicWriteJsonFile(targetPath, value) {
|
|
30
15
|
atomicWriteTextFile(targetPath, `${JSON.stringify(value, null, 2)}\n`);
|
|
@@ -188,11 +188,24 @@ function intentIsExplicitlySubsumed(commandContract, narrowerIntent, broaderInte
|
|
|
188
188
|
return (intentExplicitlySubsumedBy(commandContract, narrowerIntent, broaderIntent) ||
|
|
189
189
|
intentExplicitlySubsumes(commandContract, broaderIntent, narrowerIntent));
|
|
190
190
|
}
|
|
191
|
+
function minNumber(values) {
|
|
192
|
+
let minimum = null;
|
|
193
|
+
for (const value of values) {
|
|
194
|
+
minimum = minimum === null ? value : Math.min(minimum, value);
|
|
195
|
+
}
|
|
196
|
+
return minimum;
|
|
197
|
+
}
|
|
191
198
|
function selectVerificationCandidates(commandContract, candidates) {
|
|
192
199
|
const runnableCandidates = candidates.filter((candidate) => candidate.status === 'runnable' && candidate.intent.length > 0);
|
|
193
200
|
const selectedIntents = new Set(runnableCandidates.map((candidate) => candidate.intent));
|
|
194
201
|
for (const candidate of runnableCandidates) {
|
|
195
|
-
const isSubsumed = runnableCandidates.some((other) =>
|
|
202
|
+
const isSubsumed = runnableCandidates.some((other) => {
|
|
203
|
+
if (other.intent === candidate.intent) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return (intentIsExplicitlySubsumed(commandContract, candidate.intent, other.intent) &&
|
|
207
|
+
!intentIsExplicitlySubsumed(commandContract, other.intent, candidate.intent));
|
|
208
|
+
});
|
|
196
209
|
if (isSubsumed) {
|
|
197
210
|
selectedIntents.delete(candidate.intent);
|
|
198
211
|
}
|
|
@@ -217,7 +230,10 @@ function selectVerificationCandidates(commandContract, candidates) {
|
|
|
217
230
|
if (costs.some((cost) => cost === null)) {
|
|
218
231
|
continue;
|
|
219
232
|
}
|
|
220
|
-
const minCost =
|
|
233
|
+
const minCost = minNumber(costs);
|
|
234
|
+
if (minCost === null) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
221
237
|
const winners = group.filter((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent) === minCost);
|
|
222
238
|
if (winners.length !== 1) {
|
|
223
239
|
continue;
|
|
@@ -280,8 +280,8 @@ function pushCoverageFinding(issues, findings, severity, code, reason, intent, i
|
|
|
280
280
|
});
|
|
281
281
|
pushIssue(issues, severity, code, intent, message);
|
|
282
282
|
}
|
|
283
|
-
function configuredIntentIsRunnable(intent) {
|
|
284
|
-
return evaluateCommandIntentEligibility(
|
|
283
|
+
function configuredIntentIsRunnable(intentName, intent) {
|
|
284
|
+
return evaluateCommandIntentEligibility(intentName, intent).ok;
|
|
285
285
|
}
|
|
286
286
|
function lintIntent(name, value, issues) {
|
|
287
287
|
if (!commandIntentNameIsSafe(name)) {
|
|
@@ -582,7 +582,7 @@ export function lintCommandContract(contract, options = {}) {
|
|
|
582
582
|
summary: {
|
|
583
583
|
totalIntents: intentEntries.length,
|
|
584
584
|
configured: validIntents.filter((intent) => readString(intent, 'status') === 'configured').length,
|
|
585
|
-
runnable:
|
|
585
|
+
runnable: intentTables.filter(([name, intent]) => configuredIntentIsRunnable(name, intent)).length,
|
|
586
586
|
manualOnly: validIntents.filter((intent) => readString(intent, 'status') === 'manual_only').length,
|
|
587
587
|
unknown: validIntents.filter((intent) => readString(intent, 'status') === 'unknown').length,
|
|
588
588
|
errors,
|
|
@@ -124,7 +124,7 @@ export function updateRepeatedFailureState(input) {
|
|
|
124
124
|
requires_new_evidence: UNRESOLVED_VERIFY_STATUSES.has(input.status) && seenCount >= 2,
|
|
125
125
|
};
|
|
126
126
|
const nextFingerprints = [summary, ...state.fingerprints.filter((entry) => entry.fingerprint !== summary.fingerprint)]
|
|
127
|
-
.sort((left, right) => right.last_seen_at
|
|
127
|
+
.sort((left, right) => (left.last_seen_at > right.last_seen_at ? -1 : left.last_seen_at < right.last_seen_at ? 1 : 0))
|
|
128
128
|
.slice(0, REPEATED_FAILURE_STATE_LIMIT);
|
|
129
129
|
writeRepeatedFailureState(input.projectRoot, {
|
|
130
130
|
schema_version: '1',
|
|
@@ -4,6 +4,7 @@ import { existsSync, lstatSync, readFileSync, readlinkSync, readdirSync } from '
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { normalizeCommandEffects } from './command-effects.js';
|
|
6
6
|
const MAX_SNAPSHOT_FILES = 20_000;
|
|
7
|
+
const MAX_SNAPSHOT_DIRECTORY_DEPTH = 200;
|
|
7
8
|
const MAX_REPORTED_PATHS = 200;
|
|
8
9
|
const GIT_STATUS_TIMEOUT_MS = 10_000;
|
|
9
10
|
const GIT_STATUS_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
@@ -40,33 +41,41 @@ function signatureForPath(fullPath) {
|
|
|
40
41
|
}
|
|
41
42
|
function signatureForGitStatusPath(projectRoot, relativePath, status) {
|
|
42
43
|
const fullPath = path.join(projectRoot, ...relativePath.split('/'));
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
try {
|
|
45
|
+
if (!existsSync(fullPath)) {
|
|
46
|
+
return `git:${status}:missing`;
|
|
47
|
+
}
|
|
48
|
+
const stat = lstatSync(fullPath);
|
|
49
|
+
if (stat.isSymbolicLink()) {
|
|
50
|
+
return `git:${status}:symlink:${readlinkSync(fullPath)}`;
|
|
51
|
+
}
|
|
52
|
+
if (!stat.isFile()) {
|
|
53
|
+
return `git:${status}:${stat.isDirectory() ? 'directory' : 'other'}:${stat.size}:${stat.mtimeMs}`;
|
|
54
|
+
}
|
|
55
|
+
if (stat.size > MAX_HASH_BYTES) {
|
|
56
|
+
return `git:${status}:file:${stat.size}:${stat.mtimeMs}:unhashed`;
|
|
57
|
+
}
|
|
58
|
+
const digest = createHash('sha256').update(readFileSync(fullPath)).digest('hex');
|
|
59
|
+
return `git:${status}:file:${stat.size}:${digest}`;
|
|
52
60
|
}
|
|
53
|
-
|
|
54
|
-
return `git:${status}:
|
|
61
|
+
catch {
|
|
62
|
+
return `git:${status}:missing`;
|
|
55
63
|
}
|
|
56
|
-
const digest = createHash('sha256').update(readFileSync(fullPath)).digest('hex');
|
|
57
|
-
return `git:${status}:file:${stat.size}:${digest}`;
|
|
58
64
|
}
|
|
59
|
-
function collectSnapshotEntries(
|
|
65
|
+
function collectSnapshotEntries(currentPath, currentRelativePath, depth, entries) {
|
|
66
|
+
if (depth > MAX_SNAPSHOT_DIRECTORY_DEPTH) {
|
|
67
|
+
throw new Error('snapshot_directory_depth_limit_exceeded');
|
|
68
|
+
}
|
|
60
69
|
const names = readdirSync(currentPath).sort((left, right) => left.localeCompare(right));
|
|
61
70
|
for (const name of names) {
|
|
62
71
|
const fullPath = path.join(currentPath, name);
|
|
63
|
-
const relativePath =
|
|
72
|
+
const relativePath = currentRelativePath === '.' ? name : `${currentRelativePath}/${name}`;
|
|
64
73
|
const stat = lstatSync(fullPath);
|
|
65
74
|
if (stat.isDirectory()) {
|
|
66
75
|
if (isExcludedDirectory(relativePath, name)) {
|
|
67
76
|
continue;
|
|
68
77
|
}
|
|
69
|
-
collectSnapshotEntries(
|
|
78
|
+
collectSnapshotEntries(fullPath, relativePath, depth + 1, entries);
|
|
70
79
|
continue;
|
|
71
80
|
}
|
|
72
81
|
if (entries.size >= MAX_SNAPSHOT_FILES) {
|
|
@@ -90,7 +99,7 @@ function captureSnapshot(projectRoot, env) {
|
|
|
90
99
|
}
|
|
91
100
|
try {
|
|
92
101
|
const entries = new Map();
|
|
93
|
-
collectSnapshotEntries(projectRoot,
|
|
102
|
+
collectSnapshotEntries(projectRoot, '.', 0, entries);
|
|
94
103
|
return { status: 'checked', entries, reason: null, source: 'recursive_snapshot' };
|
|
95
104
|
}
|
|
96
105
|
catch (error) {
|
|
@@ -145,6 +154,10 @@ function captureGitStatusSnapshot(projectRoot, env) {
|
|
|
145
154
|
}
|
|
146
155
|
entries.set(filePath, signatureForGitStatusPath(projectRoot, filePath, status));
|
|
147
156
|
if (status.includes('R') || status.includes('C')) {
|
|
157
|
+
const sourcePath = normalizeRelativePath(parts[index + 1] ?? '');
|
|
158
|
+
if (status.includes('R') && sourcePath.length > 0) {
|
|
159
|
+
entries.set(sourcePath, `git:${status}:missing`);
|
|
160
|
+
}
|
|
148
161
|
index += 1;
|
|
149
162
|
}
|
|
150
163
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
const NOFOLLOW_FLAG = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
5
|
+
const WINDOWS_RENAME_RETRY_DELAYS_MS = [10, 25, 50, 100, 200];
|
|
6
|
+
const WINDOWS_RENAME_RETRY_CODES = new Set(['EBUSY', 'ENOTEMPTY', 'EPERM']);
|
|
7
|
+
const WRITE_SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
8
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
5
9
|
function isMissingPathError(error) {
|
|
6
10
|
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
7
11
|
}
|
|
@@ -9,6 +13,30 @@ function tempFilePath(targetPath) {
|
|
|
9
13
|
const suffix = `${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`;
|
|
10
14
|
return path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${suffix}.tmp`);
|
|
11
15
|
}
|
|
16
|
+
function sleep(milliseconds) {
|
|
17
|
+
Atomics.wait(WRITE_SLEEP_BUFFER, 0, 0, milliseconds);
|
|
18
|
+
}
|
|
19
|
+
function isRetryableWindowsRenameError(error) {
|
|
20
|
+
if (process.platform !== 'win32' || !error || typeof error !== 'object' || !('code' in error)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return WINDOWS_RENAME_RETRY_CODES.has(String(error.code));
|
|
24
|
+
}
|
|
25
|
+
function renameWithWindowsRetry(sourcePath, targetPath) {
|
|
26
|
+
for (let attempt = 0;; attempt += 1) {
|
|
27
|
+
try {
|
|
28
|
+
renameSync(sourcePath, targetPath);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
const delay = WINDOWS_RENAME_RETRY_DELAYS_MS[attempt];
|
|
33
|
+
if (delay === undefined || !isRetryableWindowsRenameError(error)) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
sleep(delay);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
12
40
|
export function ensureInside(parentPath, childPath) {
|
|
13
41
|
const parent = path.resolve(parentPath);
|
|
14
42
|
const child = path.resolve(childPath);
|
|
@@ -42,13 +70,29 @@ export function ensureInsideWithoutSymlinks(parentPath, childPath, options = {})
|
|
|
42
70
|
}
|
|
43
71
|
}
|
|
44
72
|
catch (error) {
|
|
45
|
-
if (isMissingPathError(error) && options.allowMissingLeaf) {
|
|
73
|
+
if (isMissingPathError(error) && (options.allowMissingDescendant || (isLeaf && options.allowMissingLeaf))) {
|
|
46
74
|
return;
|
|
47
75
|
}
|
|
48
76
|
throw error;
|
|
49
77
|
}
|
|
50
78
|
}
|
|
51
79
|
}
|
|
80
|
+
function readBoundedFileDescriptor(fileDescriptor, childPath, maxBytes) {
|
|
81
|
+
const chunks = [];
|
|
82
|
+
let totalBytes = 0;
|
|
83
|
+
while (true) {
|
|
84
|
+
const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes + 1 - totalBytes));
|
|
85
|
+
const bytesRead = readSync(fileDescriptor, chunk, 0, chunk.byteLength, null);
|
|
86
|
+
if (bytesRead === 0) {
|
|
87
|
+
return Buffer.concat(chunks, totalBytes);
|
|
88
|
+
}
|
|
89
|
+
totalBytes += bytesRead;
|
|
90
|
+
if (totalBytes > maxBytes) {
|
|
91
|
+
throw new Error(`File exceeds maximum size ${maxBytes} bytes: ${childPath}`);
|
|
92
|
+
}
|
|
93
|
+
chunks.push(bytesRead === chunk.byteLength ? chunk : chunk.subarray(0, bytesRead));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
52
96
|
function ensureDirectoryInsideWithoutSymlinks(parentPath, directoryPath) {
|
|
53
97
|
ensureInside(parentPath, directoryPath);
|
|
54
98
|
const parent = path.resolve(parentPath);
|
|
@@ -86,7 +130,7 @@ function ensureDirectoryInsideWithoutSymlinks(parentPath, directoryPath) {
|
|
|
86
130
|
export function ensureFileTargetInsideWithoutSymlinks(parentPath, childPath, options = {}) {
|
|
87
131
|
const absoluteChildPath = path.resolve(childPath);
|
|
88
132
|
ensureInside(parentPath, absoluteChildPath);
|
|
89
|
-
ensureInsideWithoutSymlinks(parentPath, path.dirname(absoluteChildPath), {
|
|
133
|
+
ensureInsideWithoutSymlinks(parentPath, path.dirname(absoluteChildPath), { allowMissingDescendant: true });
|
|
90
134
|
try {
|
|
91
135
|
const stats = lstatSync(absoluteChildPath);
|
|
92
136
|
if (stats.isSymbolicLink()) {
|
|
@@ -108,6 +152,9 @@ export function readFileInsideWithoutSymlinks(parentPath, childPath, options = {
|
|
|
108
152
|
ensureInsideWithoutSymlinks(parentPath, absoluteChildPath);
|
|
109
153
|
const fileDescriptor = openSync(absoluteChildPath, constants.O_RDONLY | NOFOLLOW_FLAG);
|
|
110
154
|
try {
|
|
155
|
+
if (NOFOLLOW_FLAG === 0 && lstatSync(absoluteChildPath).isSymbolicLink()) {
|
|
156
|
+
throw new Error(`Path must not contain symlinks: ${childPath}`);
|
|
157
|
+
}
|
|
111
158
|
const stats = fstatSync(fileDescriptor);
|
|
112
159
|
if (!stats.isFile()) {
|
|
113
160
|
throw new Error(`Path must be a regular file: ${childPath}`);
|
|
@@ -115,7 +162,9 @@ export function readFileInsideWithoutSymlinks(parentPath, childPath, options = {
|
|
|
115
162
|
if (options.maxBytes !== undefined && stats.size > options.maxBytes) {
|
|
116
163
|
throw new Error(`File exceeds maximum size ${options.maxBytes} bytes: ${childPath}`);
|
|
117
164
|
}
|
|
118
|
-
return
|
|
165
|
+
return options.maxBytes === undefined
|
|
166
|
+
? readFileSync(fileDescriptor)
|
|
167
|
+
: readBoundedFileDescriptor(fileDescriptor, childPath, options.maxBytes);
|
|
119
168
|
}
|
|
120
169
|
finally {
|
|
121
170
|
closeSync(fileDescriptor);
|
|
@@ -137,7 +186,7 @@ export function writeFileInsideWithoutSymlinks(parentPath, childPath, content) {
|
|
|
137
186
|
closeSync(fileDescriptor);
|
|
138
187
|
fileDescriptor = null;
|
|
139
188
|
ensureFileTargetInsideWithoutSymlinks(parentPath, absoluteChildPath, { allowMissingLeaf: true });
|
|
140
|
-
|
|
189
|
+
renameWithWindowsRetry(temporaryPath, absoluteChildPath);
|
|
141
190
|
}
|
|
142
191
|
catch (error) {
|
|
143
192
|
if (fileDescriptor !== null) {
|
|
@@ -16,7 +16,8 @@ function readFrontmatterLines(content) {
|
|
|
16
16
|
if (!content.startsWith('---')) {
|
|
17
17
|
return [];
|
|
18
18
|
}
|
|
19
|
-
const
|
|
19
|
+
const endMatch = /\n---(?:\r?\n|$)/u.exec(content.slice(3));
|
|
20
|
+
const end = endMatch ? 3 + endMatch.index : -1;
|
|
20
21
|
if (end < 0) {
|
|
21
22
|
return [];
|
|
22
23
|
}
|
|
@@ -21,6 +21,7 @@ export const SOURCE_ANCHOR_GENERATED_PATH_PARTS = new Set([
|
|
|
21
21
|
'vendor',
|
|
22
22
|
]);
|
|
23
23
|
export const SOURCE_ANCHOR_ALLOWED_FIELDS = new Set(['purpose', 'search', 'invariant', 'risk']);
|
|
24
|
+
const MAX_SOURCE_ANCHOR_DIRECTORY_DEPTH = 200;
|
|
24
25
|
export const SOURCE_ANCHOR_ALLOWED_RISKS = new Set([
|
|
25
26
|
'authn',
|
|
26
27
|
'authz',
|
|
@@ -79,10 +80,13 @@ function fileIsWithinSizeLimit(filePath, maxFileBytes) {
|
|
|
79
80
|
return false;
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
|
-
function listFilesRecursive(root, options, current = root) {
|
|
83
|
+
function listFilesRecursive(root, options, current = root, depth = 0) {
|
|
83
84
|
if (!existsSync(current)) {
|
|
84
85
|
return [];
|
|
85
86
|
}
|
|
87
|
+
if (depth > MAX_SOURCE_ANCHOR_DIRECTORY_DEPTH) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
86
90
|
const currentRealPath = realpathSync(current);
|
|
87
91
|
if (!pathIsInsideRoot(options.rootRealPath, currentRealPath) || options.visitedRealDirectories.has(currentRealPath)) {
|
|
88
92
|
return [];
|
|
@@ -96,7 +100,7 @@ function listFilesRecursive(root, options, current = root) {
|
|
|
96
100
|
continue;
|
|
97
101
|
}
|
|
98
102
|
if (entry.isDirectory()) {
|
|
99
|
-
files.push(...listFilesRecursive(root, options, entryPath));
|
|
103
|
+
files.push(...listFilesRecursive(root, options, entryPath, depth + 1));
|
|
100
104
|
continue;
|
|
101
105
|
}
|
|
102
106
|
if (entry.isSymbolicLink()) {
|
|
@@ -121,7 +125,7 @@ function listFilesRecursive(root, options, current = root) {
|
|
|
121
125
|
continue;
|
|
122
126
|
}
|
|
123
127
|
if (stat.isDirectory()) {
|
|
124
|
-
files.push(...listFilesRecursive(root, options, entryPath));
|
|
128
|
+
files.push(...listFilesRecursive(root, options, entryPath, depth + 1));
|
|
125
129
|
continue;
|
|
126
130
|
}
|
|
127
131
|
if (stat.isFile()) {
|
|
@@ -2,6 +2,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createCommandEnv } from './command-env.js';
|
|
5
|
+
const GIT_DIFF_TIMEOUT_MS = 10_000;
|
|
6
|
+
const GIT_DIFF_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
5
7
|
const TEST_CHANGE_KINDS = new Set(['test', 'test_fixture']);
|
|
6
8
|
const SKIP_OR_ONLY_MARKER = /\b(?:describe|it|test)\s*\.\s*(?:skip|only)\s*\(/u;
|
|
7
9
|
const TODO_OR_PENDING_MARKER = /\b(?:describe|it|test)\s*\.\s*(?:todo|pending)\s*\(/u;
|
|
@@ -49,30 +51,66 @@ function fileTextIfReadable(projectRoot, relativePath) {
|
|
|
49
51
|
return null;
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
function normalizeGitDiffPath(value) {
|
|
55
|
+
return value
|
|
56
|
+
.replace(/^"(.*)"$/u, '$1')
|
|
57
|
+
.replace(/^(?:a|b)\//u, '')
|
|
58
|
+
.replaceAll('\\', '/');
|
|
59
|
+
}
|
|
60
|
+
function parseGitDiffLines(stdout) {
|
|
61
|
+
const byPath = new Map();
|
|
62
|
+
let oldPath = null;
|
|
63
|
+
let currentPath = null;
|
|
64
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
65
|
+
if (line.startsWith('--- ')) {
|
|
66
|
+
const rawPath = line.slice(4).trim();
|
|
67
|
+
oldPath = rawPath === '/dev/null' ? null : normalizeGitDiffPath(rawPath);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (line.startsWith('+++ ')) {
|
|
71
|
+
const rawPath = line.slice(4).trim();
|
|
72
|
+
currentPath = rawPath === '/dev/null' ? oldPath : normalizeGitDiffPath(rawPath);
|
|
73
|
+
if (currentPath && !byPath.has(currentPath)) {
|
|
74
|
+
byPath.set(currentPath, { added: [], removed: [] });
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!currentPath || line.startsWith('@@')) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const diff = byPath.get(currentPath);
|
|
82
|
+
if (!diff) {
|
|
66
83
|
continue;
|
|
67
84
|
}
|
|
68
85
|
if (line.startsWith('+')) {
|
|
69
|
-
added.push(line.slice(1));
|
|
86
|
+
diff.added.push(line.slice(1));
|
|
70
87
|
}
|
|
71
88
|
else if (line.startsWith('-')) {
|
|
72
|
-
removed.push(line.slice(1));
|
|
89
|
+
diff.removed.push(line.slice(1));
|
|
73
90
|
}
|
|
74
91
|
}
|
|
75
|
-
return
|
|
92
|
+
return new Map([...byPath.entries()].map(([filePath, diff]) => [
|
|
93
|
+
filePath,
|
|
94
|
+
{ added: diff.added, removed: diff.removed },
|
|
95
|
+
]));
|
|
96
|
+
}
|
|
97
|
+
function gitDiffLinesByPath(projectRoot, relativePaths) {
|
|
98
|
+
const uniquePaths = [...new Set(relativePaths)].filter((relativePath) => resolveInsideRoot(projectRoot, relativePath) !== null);
|
|
99
|
+
if (uniquePaths.length === 0) {
|
|
100
|
+
return new Map();
|
|
101
|
+
}
|
|
102
|
+
const result = spawnSync('git', ['diff', '--no-ext-diff', '--unified=0', '--', ...uniquePaths], {
|
|
103
|
+
cwd: projectRoot,
|
|
104
|
+
encoding: 'utf8',
|
|
105
|
+
env: createCommandEnv(projectRoot, { policy: 'minimal', allowlist: [] }),
|
|
106
|
+
maxBuffer: GIT_DIFF_MAX_BUFFER_BYTES,
|
|
107
|
+
timeout: GIT_DIFF_TIMEOUT_MS,
|
|
108
|
+
windowsHide: true,
|
|
109
|
+
});
|
|
110
|
+
if (result.status !== 0 || typeof result.stdout !== 'string' || result.stdout.length === 0) {
|
|
111
|
+
return new Map();
|
|
112
|
+
}
|
|
113
|
+
return parseGitDiffLines(result.stdout);
|
|
76
114
|
}
|
|
77
115
|
function countMatching(lines, pattern) {
|
|
78
116
|
return lines.filter((line) => pattern.test(line)).length;
|
|
@@ -105,6 +143,9 @@ export function countValidationRatchetVerdictEffects(risks) {
|
|
|
105
143
|
export function createValidationRatchetRisks(report, projectRoot) {
|
|
106
144
|
const risks = [];
|
|
107
145
|
const seenRisks = new Set();
|
|
146
|
+
const changedDiffs = report.source === 'changed'
|
|
147
|
+
? gitDiffLinesByPath(projectRoot, report.classifications.map((classification) => classification.path))
|
|
148
|
+
: new Map();
|
|
108
149
|
function addRisk(code, severity, pathText, detail) {
|
|
109
150
|
const key = `${pathText}\0${code}`;
|
|
110
151
|
if (seenRisks.has(key)) {
|
|
@@ -115,7 +156,9 @@ export function createValidationRatchetRisks(report, projectRoot) {
|
|
|
115
156
|
}
|
|
116
157
|
for (const classification of report.classifications) {
|
|
117
158
|
const resolvedPath = resolveInsideRoot(projectRoot, classification.path);
|
|
118
|
-
const diff = report.source === 'changed'
|
|
159
|
+
const diff = report.source === 'changed'
|
|
160
|
+
? changedDiffs.get(classification.path) ?? { added: [], removed: [] }
|
|
161
|
+
: { added: [], removed: [] };
|
|
119
162
|
const addedText = diff.added.join('\n');
|
|
120
163
|
if (isTestClassification(classification)) {
|
|
121
164
|
if (report.source === 'changed' && (resolvedPath === null || !existsSync(resolvedPath))) {
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { isRecord, readString, readStringArray, } from './config-loading.js';
|
|
2
3
|
export const VERIFICATION_DECISION_GRAPH_SCHEMA_VERSION = '1';
|
|
3
4
|
function stableIdPart(value) {
|
|
4
|
-
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
if (trimmed.length === 0) {
|
|
7
|
+
return 'none';
|
|
8
|
+
}
|
|
9
|
+
const readable = trimmed.replace(/[^A-Za-z0-9_.-]+/gu, '_') || 'value';
|
|
10
|
+
const digest = createHash('sha256').update(trimmed).digest('hex').slice(0, 10);
|
|
11
|
+
return `${readable}_${digest}`;
|
|
5
12
|
}
|
|
6
13
|
function readBoolean(table, key) {
|
|
7
14
|
const value = table[key];
|