@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.14
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 +233 -0
- package/README.md +22 -5
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/capability-stewardship.md +2 -4
- package/docs/ingestion.md +3 -3
- package/docs/inquiry-harnesses.md +2 -3
- package/docs/install.md +3 -3
- package/docs/learning-harnesses.md +2 -3
- package/docs/local-services.md +42 -3
- package/docs/obsidian-contract.md +23 -0
- package/docs/obsidian-plugin.md +214 -3
- package/docs/obsidian.md +271 -11
- package/docs/personal-workspace.md +13 -4
- package/docs/release-engineering.md +2 -1
- package/docs/repo-boundary-guard.md +26 -3
- package/docs/semantic-operation.md +4 -3
- package/docs/upgrade.md +174 -2
- package/fixtures/templates/local-library.v1.json +2 -1
- package/package.json +2 -1
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/src/boundary/content-rules.mjs +532 -67
- package/src/boundary/policy.mjs +135 -56
- package/src/cli/run.mjs +3 -2
- package/src/commands/obsidian.mjs +23 -7
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +19 -1
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +17 -1
- package/src/project/path-match.mjs +2 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
- package/src/projection/obsidian/personal-workspace.mjs +87 -4
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +72 -5
- package/src/projection/project.mjs +3 -2
- package/src/runtime/obsidian/engine.mjs +293 -55
- package/src/runtime/obsidian/lifecycle.mjs +17 -12
- package/src/runtime/obsidian/login-item.mjs +4 -1
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +3 -2
- package/src/runtime/obsidian/personal-selection.mjs +125 -0
- package/src/runtime/obsidian/pipeline.mjs +44 -3
- package/src/runtime/obsidian/private-lock.mjs +39 -4
- package/src/runtime/obsidian/service-main.mjs +58 -12
- package/src/runtime/obsidian/service.mjs +66 -6
- package/src/runtime/obsidian/view-permission.mjs +334 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +19 -7
- package/templates/external-project-workspace/pack-lifecycle.json +2 -1
package/src/boundary/policy.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { matchesPathPattern } from '../project/path-match.mjs'
|
|
|
7
7
|
import { sanitizedGitEnvironment } from '../runtime/git-adapter.mjs'
|
|
8
8
|
import {
|
|
9
9
|
CHECK_AGGREGATE_MAX_BYTES,
|
|
10
|
+
canonicalDiffArgs,
|
|
11
|
+
gitOutputResult,
|
|
12
|
+
parseDiffSections,
|
|
10
13
|
parsePushRefInput,
|
|
11
14
|
resolveContentRules,
|
|
12
15
|
scanPushUpdate,
|
|
@@ -358,17 +361,83 @@ function actorFindings({ policy, project, actor, gitExecutable, allowNetworkActo
|
|
|
358
361
|
return findings
|
|
359
362
|
}
|
|
360
363
|
|
|
361
|
-
|
|
364
|
+
/**
|
|
365
|
+
* The index a pre-commit hook is judging. Git names it in GIT_INDEX_FILE (a
|
|
366
|
+
* temporary index under `git commit -a` or `git commit <paths>`, a linked
|
|
367
|
+
* worktree's own index, or `.git/index` relative to the work tree's top) and
|
|
368
|
+
* runs the hook at that top, though a chained hook may have moved. The work
|
|
369
|
+
* tree is found by Git's own discovery from where the hook runs; the hook's
|
|
370
|
+
* GIT_DIR is not used, because in a linked worktree Git exports it without a
|
|
371
|
+
* work tree, which would make any subdirectory look like the top. A work tree
|
|
372
|
+
* discovery cannot find is unresolved, and blocks. Returns null when no index
|
|
373
|
+
* is named.
|
|
374
|
+
*/
|
|
375
|
+
export function hookIndexFromEnvironment({ env = process.env, cwd = process.cwd(), gitExecutable = 'git' } = {}) {
|
|
376
|
+
const named = typeof env.GIT_INDEX_FILE === 'string' ? env.GIT_INDEX_FILE : ''
|
|
377
|
+
if (!named) return null
|
|
378
|
+
const workTree = gitPathOutput(cwd, ['rev-parse', '--show-toplevel'], sanitizedGitEnvironment(), gitExecutable)
|
|
379
|
+
const commonDir = gitPathOutput(cwd, ['rev-parse', '--git-common-dir'], sanitizedGitEnvironment(), gitExecutable)
|
|
380
|
+
return { workTree, indexFile: path.resolve(workTree ?? cwd, named), commonDir }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function gitPathOutput(cwd, args, env, gitExecutable = 'git') {
|
|
384
|
+
const result = spawnSync(gitExecutable, ['-C', cwd, ...args], { encoding: 'utf8', env })
|
|
385
|
+
const output = result.status === 0 ? String(result.stdout ?? '').replace(/\r?\n$/, '') : ''
|
|
386
|
+
return output ? nativeRealPath(path.resolve(cwd, output)) : null
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The file system's own spelling of a path, so a configured path that differs
|
|
390
|
+
// only in case (macOS, Windows) still names the same repo.
|
|
391
|
+
function nativeRealPath(value) {
|
|
392
|
+
try {
|
|
393
|
+
return fs.realpathSync.native(path.resolve(value))
|
|
394
|
+
} catch {
|
|
395
|
+
return path.resolve(value)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Where each managed repo's staged state is read. The repo the hook is
|
|
401
|
+
* committing is read from the hook's work tree and index: the managed repo
|
|
402
|
+
* configured at that work tree, or the one managed repo sharing its Git
|
|
403
|
+
* directory (a linked worktree of it). Every other repo is read as configured.
|
|
404
|
+
* When an index is named but no single managed repo is being committed, the
|
|
405
|
+
* guard cannot tell what it is judging, and says so.
|
|
406
|
+
*/
|
|
407
|
+
function stagedTargets(project, hookIndex, gitExecutable = 'git') {
|
|
408
|
+
const repos = managedRepos(project)
|
|
409
|
+
const targets = new Map(repos.map((repo) => [repo, { root: repo.path, indexFile: null }]))
|
|
410
|
+
if (!hookIndex) return { targets, unresolved: null }
|
|
411
|
+
let committing = hookIndex.workTree ? repos.filter((repo) => repo.path && nativeRealPath(repo.path) === hookIndex.workTree) : []
|
|
412
|
+
if (committing.length === 0 && hookIndex.commonDir) {
|
|
413
|
+
committing = repos.filter((repo) => repo.path && fs.existsSync(repo.path)
|
|
414
|
+
&& gitPathOutput(repo.path, ['rev-parse', '--git-common-dir'], sanitizedGitEnvironment(), gitExecutable) === hookIndex.commonDir)
|
|
415
|
+
}
|
|
416
|
+
if (committing.length !== 1) return { targets, unresolved: hookIndex.workTree ?? '(an unresolved work tree)' }
|
|
417
|
+
targets.set(committing[0], { root: hookIndex.workTree, indexFile: hookIndex.indexFile })
|
|
418
|
+
return { targets, unresolved: null }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function stagedPathsResult(targets, { gitExecutable = 'git' } = {}) {
|
|
362
422
|
const paths = []
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
423
|
+
const failures = []
|
|
424
|
+
for (const [repo, target] of targets) {
|
|
425
|
+
if (!target.root || !fs.existsSync(path.join(target.root, '.git'))) continue
|
|
426
|
+
// NUL-separated names are exact: a quoted name could never match a forbidden-path glob.
|
|
427
|
+
const result = gitOutputResult(target.root, ['diff', '--cached', '--name-only', '-z', '--no-relative', '--diff-filter=ACMR'], { gitExecutable, indexFile: target.indexFile })
|
|
428
|
+
if (!result.ok) {
|
|
429
|
+
failures.push({ repo, reason: result.code })
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
for (const rel of result.stdout.split('\0').filter(Boolean)) {
|
|
433
|
+
paths.push({ repo: repo.name, repoRoot: target.root, path: normalize(rel) })
|
|
369
434
|
}
|
|
370
435
|
}
|
|
371
|
-
return paths
|
|
436
|
+
return { paths, failures }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function stagedPathsForProject(project, { gitExecutable = 'git', hookIndex = null } = {}) {
|
|
440
|
+
return stagedPathsResult(stagedTargets(project, hookIndex, gitExecutable).targets, { gitExecutable }).paths
|
|
372
441
|
}
|
|
373
442
|
|
|
374
443
|
function forbiddenPathFindings({ policy, stagedPaths }) {
|
|
@@ -410,30 +479,14 @@ function isInitializationDefault(field, value) {
|
|
|
410
479
|
return Array.isArray(defaults) && defaults.includes(value)
|
|
411
480
|
}
|
|
412
481
|
|
|
482
|
+
/** Diff sections with their hunks, read by the same parser as the content scan. */
|
|
413
483
|
export function diffFileSections(diff) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
current = { path: header ? normalize(header[2]) : null, hunks: [], lines: [] }
|
|
421
|
-
files.push(current)
|
|
422
|
-
hunk = null
|
|
423
|
-
continue
|
|
424
|
-
}
|
|
425
|
-
if (!current) continue
|
|
426
|
-
current.lines.push(line)
|
|
427
|
-
if (line.startsWith('@@')) {
|
|
428
|
-
hunk = { added: [], removed: [] }
|
|
429
|
-
current.hunks.push(hunk)
|
|
430
|
-
continue
|
|
431
|
-
}
|
|
432
|
-
if (!hunk) continue
|
|
433
|
-
if (/^\+(?!\+)/.test(line)) hunk.added.push(line.slice(1))
|
|
434
|
-
else if (/^-(?!-)/.test(line)) hunk.removed.push(line.slice(1))
|
|
435
|
-
}
|
|
436
|
-
return files
|
|
484
|
+
return parseDiffSections(diff, { detail: true }).map((section) => ({
|
|
485
|
+
path: section.path,
|
|
486
|
+
hunks: section.hunks,
|
|
487
|
+
lines: section.lines,
|
|
488
|
+
incomplete: section.incomplete,
|
|
489
|
+
}))
|
|
437
490
|
}
|
|
438
491
|
|
|
439
492
|
export function semanticChangesInFile(file) {
|
|
@@ -456,26 +509,38 @@ export function semanticChangesInFile(file) {
|
|
|
456
509
|
return changes
|
|
457
510
|
}
|
|
458
511
|
|
|
459
|
-
function semanticDiffFindings({
|
|
512
|
+
function semanticDiffFindings({ targets, gitExecutable = 'git' }) {
|
|
460
513
|
const findings = []
|
|
461
514
|
const severity = 'error'
|
|
462
|
-
for (const repo of
|
|
463
|
-
if (!
|
|
515
|
+
for (const [repo, target] of targets) {
|
|
516
|
+
if (!target.root || !fs.existsSync(path.join(target.root, '.git'))) {
|
|
464
517
|
findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
|
|
465
518
|
continue
|
|
466
519
|
}
|
|
467
|
-
|
|
468
|
-
|
|
520
|
+
// --text: a document declared binary, or holding a NUL byte, still has its fields read.
|
|
521
|
+
const result = gitOutputResult(target.root, [...canonicalDiffArgs(['--cached'], ['--text']), '--', '*.md', '*.kg.json'], { gitExecutable, indexFile: target.indexFile })
|
|
522
|
+
if (!result.ok) {
|
|
469
523
|
findings.push(unscannableRepoFinding(repo, 'git diff --cached failed'))
|
|
470
524
|
continue
|
|
471
525
|
}
|
|
472
526
|
if (!result.stdout.trim()) continue
|
|
473
527
|
for (const file of diffFileSections(result.stdout)) {
|
|
528
|
+
if (file.incomplete) {
|
|
529
|
+
findings.push(finding({
|
|
530
|
+
severity,
|
|
531
|
+
code: 'semantic-field-scan-incomplete',
|
|
532
|
+
repo: repo.name,
|
|
533
|
+
path: file.path,
|
|
534
|
+
message: `${repo.name}/${file.path ?? '(unknown path)'}: the staged diff could not be read, so its semantic fields were not checked`,
|
|
535
|
+
details: { reason: file.incomplete },
|
|
536
|
+
}))
|
|
537
|
+
continue
|
|
538
|
+
}
|
|
474
539
|
const changes = semanticChangesInFile(file)
|
|
475
540
|
if (!changes.length) continue
|
|
476
|
-
// The marker has to
|
|
477
|
-
// a marker elsewhere in the commit approves nothing.
|
|
478
|
-
if (
|
|
541
|
+
// The marker has to be added in the same file's diff as the change it approves:
|
|
542
|
+
// a marker elsewhere in the commit, a removed one, or one in a file name approves nothing.
|
|
543
|
+
if (file.hunks.some((hunk) => hunk.added.some((line) => REVIEW_MARKER_RE.test(line)))) continue
|
|
479
544
|
const label = `${repo.name}/${file.path ?? '(unknown path)'}`
|
|
480
545
|
findings.push(
|
|
481
546
|
finding({
|
|
@@ -492,17 +557,17 @@ function semanticDiffFindings({ policy, project, gitExecutable = 'git' }) {
|
|
|
492
557
|
return findings
|
|
493
558
|
}
|
|
494
559
|
|
|
495
|
-
function stagedContentFindings({ policy,
|
|
560
|
+
function stagedContentFindings({ policy, targets, gitExecutable = 'git' }) {
|
|
496
561
|
const rules = resolveContentRules(policy)
|
|
497
562
|
const exceptions = asArray(policy?.contentRuleExceptions)
|
|
498
563
|
const findings = []
|
|
499
564
|
let totalBytes = 0
|
|
500
|
-
for (const repo of
|
|
501
|
-
if (!
|
|
565
|
+
for (const [repo, target] of targets) {
|
|
566
|
+
if (!target.root || !fs.existsSync(path.join(target.root, '.git'))) {
|
|
502
567
|
findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
|
|
503
568
|
continue
|
|
504
569
|
}
|
|
505
|
-
const result = scanStagedRepository({ repoRoot:
|
|
570
|
+
const result = scanStagedRepository({ repoRoot: target.root, rules, exceptions, repo: repo.name, gitExecutable, indexFile: target.indexFile })
|
|
506
571
|
findings.push(...result.findings, ...result.diagnostics)
|
|
507
572
|
totalBytes += result.bytes
|
|
508
573
|
if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
|
|
@@ -545,7 +610,7 @@ function repoForCwd(project, cwd) {
|
|
|
545
610
|
* which reports without blocking, so an accepted usage elsewhere in the repo can
|
|
546
611
|
* never strand unrelated work on the machine.
|
|
547
612
|
*/
|
|
548
|
-
export function checkPushContent({ project, policy, repo, updates, cwd = process.cwd(), gitRunner = spawnSync } = {}) {
|
|
613
|
+
export function checkPushContent({ project, policy, repo, updates, remote = null, cwd = process.cwd(), gitRunner = spawnSync } = {}) {
|
|
549
614
|
const target = repo ?? repoForCwd(project, cwd)
|
|
550
615
|
if (!target) {
|
|
551
616
|
// Fail closed. This hook is only installed into managed repos, so failing to
|
|
@@ -563,15 +628,17 @@ export function checkPushContent({ project, policy, repo, updates, cwd = process
|
|
|
563
628
|
const findings = []
|
|
564
629
|
let totalBytes = 0
|
|
565
630
|
for (const update of updates) {
|
|
566
|
-
const result = scanPushUpdate({ repoRoot: target.path, update, rules, exceptions, repo: target.name, gitRunner })
|
|
631
|
+
const result = scanPushUpdate({ repoRoot: target.path, update, rules, exceptions, repo: target.name, remote, gitRunner, maxBytes: CHECK_AGGREGATE_MAX_BYTES - totalBytes })
|
|
567
632
|
findings.push(...result.findings, ...result.diagnostics)
|
|
568
633
|
totalBytes += result.bytes
|
|
634
|
+
// The per-commit scan reports its own budget refusal; say it once.
|
|
635
|
+
if (result.diagnostics.some((item) => item.details?.reason === 'push-evidence-too-large')) break
|
|
569
636
|
if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
|
|
570
637
|
findings.push(finding({
|
|
571
638
|
severity: 'error',
|
|
572
639
|
code: 'content-scan-incomplete',
|
|
573
640
|
repo: target.name,
|
|
574
|
-
message: `push evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit`,
|
|
641
|
+
message: `push evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit; push it in smaller parts`,
|
|
575
642
|
}))
|
|
576
643
|
break
|
|
577
644
|
}
|
|
@@ -665,7 +732,7 @@ function promotionFindings({ policy, project, graph }) {
|
|
|
665
732
|
return findings
|
|
666
733
|
}
|
|
667
734
|
|
|
668
|
-
export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null, gitExecutable = 'git', allowNetworkActorResolution = true, allowHistoryActorResolution = false, forceActorErrors = false, delegations = null, ownerKeys = null, now = Date.now() } = {}) {
|
|
735
|
+
export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null, gitExecutable = 'git', allowNetworkActorResolution = true, allowHistoryActorResolution = false, forceActorErrors = false, delegations = null, ownerKeys = null, now = Date.now(), hookIndex = null } = {}) {
|
|
669
736
|
const validationErrors = validateBoundaryPolicy(policy, project)
|
|
670
737
|
let graph = null
|
|
671
738
|
const findings = validationErrors.map((message) => finding({ severity: 'error', code: 'boundary-policy-invalid', message }))
|
|
@@ -684,10 +751,18 @@ export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnl
|
|
|
684
751
|
const operation = staged ? 'pre-commit' : 'boundary-check'
|
|
685
752
|
findings.push(...actorFindings({ policy, project, actor, gitExecutable, allowNetworkActorResolution, allowHistoryActorResolution, forceActorErrors, operation, delegations: delegationList, ownerKeys, now }))
|
|
686
753
|
if (staged) {
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
754
|
+
const { targets, unresolved } = stagedTargets(project, hookIndex, gitExecutable)
|
|
755
|
+
if (unresolved) {
|
|
756
|
+
findings.push(finding({
|
|
757
|
+
code: 'pre-commit-repo-unresolved',
|
|
758
|
+
message: `${unresolved} is not a repo declared in ${project?.configPath ?? 'the project config'}, or several declared repos share its Git directory; the pre-commit guard cannot tell what is being committed`,
|
|
759
|
+
}))
|
|
760
|
+
}
|
|
761
|
+
const listed = stagedPathsResult(targets, { gitExecutable })
|
|
762
|
+
findings.push(...listed.failures.map(({ repo, reason }) => unscannableRepoFinding(repo, `staged paths could not be listed (${reason})`)))
|
|
763
|
+
findings.push(...forbiddenPathFindings({ policy, stagedPaths: listed.paths }))
|
|
764
|
+
findings.push(...semanticDiffFindings({ targets, gitExecutable }))
|
|
765
|
+
findings.push(...stagedContentFindings({ policy, targets, gitExecutable }))
|
|
691
766
|
}
|
|
692
767
|
const errors = findings.filter((item) => item.severity === 'error')
|
|
693
768
|
const warnings = findings.filter((item) => item.severity !== 'error')
|
|
@@ -715,7 +790,7 @@ export function runBoundaryCheckCommand(argv = process.argv.slice(2)) {
|
|
|
715
790
|
// Owner keys are read only when a delegations document exists.
|
|
716
791
|
const ownerKeys = sidecar.present && sidecar.document ? ownerKeysForCommand() : null
|
|
717
792
|
let report = loaded.ok
|
|
718
|
-
? checkBoundaryPolicy({ project, policy: loaded.policy, staged, stagedOnly, actor: firstString(args.actor), delegations: sidecar.document, ownerKeys })
|
|
793
|
+
? checkBoundaryPolicy({ project, policy: loaded.policy, staged, stagedOnly, actor: firstString(args.actor), delegations: sidecar.document, ownerKeys, hookIndex: staged ? hookIndexFromEnvironment() : null })
|
|
719
794
|
: { ok: false, mode: null, schema: BOUNDARY_POLICY_SCHEMA, graphCounts: null, findings: missingFindings, errors: missingFindings, warnings: [] }
|
|
720
795
|
if (sidecarFindings.length) report = { ...report, ok: false, findings: [...sidecarFindings, ...report.findings], errors: [...sidecarFindings, ...report.errors] }
|
|
721
796
|
|
|
@@ -784,7 +859,7 @@ ATELIER_HOOK_PROJECT_CONFIG="$ATELIER_HOOK_REPO_ROOT"/${shellSingleQuote(portabl
|
|
|
784
859
|
// pre-push reads the ref updates git writes to stdin and judges only that range;
|
|
785
860
|
// pre-commit judges the staged diff. Neither scans the whole tree — that view is
|
|
786
861
|
// `atelier boundary audit`, which reports without blocking.
|
|
787
|
-
const invocation = hookName === 'pre-push' ? `boundary push-check ${config}` : `boundary check --staged ${config}`
|
|
862
|
+
const invocation = hookName === 'pre-push' ? `boundary push-check ${config} --remote="\${1:-}"` : `boundary check --staged ${config}`
|
|
788
863
|
return `#!/usr/bin/env bash
|
|
789
864
|
# MNSTRY_ATELIER_BOUNDARY_GUARD ${hookName}
|
|
790
865
|
set -euo pipefail
|
|
@@ -830,13 +905,17 @@ export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdi
|
|
|
830
905
|
console.log('[boundary:push-check] no ref updates on stdin · nothing to judge')
|
|
831
906
|
process.exit(0)
|
|
832
907
|
}
|
|
833
|
-
|
|
908
|
+
// The installed hook passes the remote Git names as its first argument.
|
|
909
|
+
const report = checkPushContent({ project, policy: loaded.policy, updates: parsed.updates, remote: firstString(args.remote), cwd: process.cwd() })
|
|
834
910
|
if (args.json) {
|
|
835
911
|
console.log(JSON.stringify(report, null, 2))
|
|
836
912
|
} else {
|
|
837
913
|
console.log(`[boundary:push-check] ${report.ok ? 'passed' : 'blocked'} · ${report.repo ?? 'unknown repo'} · ${report.errors.length} errors · ${report.warnings.length} warnings`)
|
|
838
|
-
for (const item of report.findings.slice(0, 50))
|
|
839
|
-
|
|
914
|
+
for (const item of report.findings.slice(0, 50)) {
|
|
915
|
+
const also = item.commits?.length ? ` (also in commits ${item.commits.map((commit) => commit.slice(0, 12)).join(', ')})` : ''
|
|
916
|
+
console.log(`${item.severity} ${item.rule ?? item.code}: ${item.message}${also}`)
|
|
917
|
+
}
|
|
918
|
+
if (!report.ok && report.repo && report.errors.some((item) => item.code === 'content-rule-violation')) {
|
|
840
919
|
console.log('')
|
|
841
920
|
console.log('If this usage is intentional, declare it in the boundary policy:')
|
|
842
921
|
console.log(' contentRuleExceptions: [{ rule, repo, paths: [...], reason: "why this is accepted" }]')
|
package/src/cli/run.mjs
CHANGED
|
@@ -150,6 +150,7 @@ Core commands:
|
|
|
150
150
|
upgrade explain --plan Explain saved changes and consent without writes.
|
|
151
151
|
upgrade apply --plan --confirm Apply the selected plan through commit hooks.
|
|
152
152
|
upgrade status|recover Inspect receipts or preview recovery.
|
|
153
|
+
upgrade closure --npm-root Plan a consumer dependency-closure check; reads only, proves nothing.
|
|
153
154
|
upgrade --dry-run Plan a legacy package/template upgrade.
|
|
154
155
|
upgrade --apply Apply a branch-based reviewable upgrade.
|
|
155
156
|
lock check|write Verify or create atelier.lock.json.
|
|
@@ -279,7 +280,7 @@ Assembles a local feedback report under ignored .atelier-local/feedback/ (mode 0
|
|
|
279
280
|
announcements: `Usage: ${c} announcements list [--dir DIR] [--public-key FILE] | verify <file> [--public-key FILE] [--json] | show <file> [--public-key FILE]
|
|
280
281
|
|
|
281
282
|
MNSTRY announcements are a pull-only channel: signed JSON documents under announcements/ in the repository. The trust anchor is always the committed MNSTRY key, or one you pass explicitly with --public-key; --dir changes only where documents are read from and never which key verifies them. Every run names the key and keyId it used. The kit never fetches anything — receiving announcements is the git pull you chose to run, and show refuses to print a body whose signature does not verify.`,
|
|
282
|
-
obsidian: `Usage: ${c} obsidian status|settings|location|scope|view|audience|mode|policy|service|open|uninstall|plugin|apply|conflicts|apply-policy|selection|proposals [--project atelier.project.json] [--data-root DIR] [--json] [--no-input]
|
|
283
|
+
obsidian: `Usage: ${c} obsidian status|settings|location|scope|view|audience|mode|policy|service|open|uninstall|plugin|apply|conflicts|apply-policy|selection|proposals [--project atelier.project.json | --personal-home DIR] [--data-root DIR] [--json] [--no-input]
|
|
283
284
|
|
|
284
285
|
${c} obsidian status
|
|
285
286
|
${c} obsidian settings
|
|
@@ -300,7 +301,7 @@ MNSTRY announcements are a pull-only channel: signed JSON documents under announ
|
|
|
300
301
|
${c} obsidian selection resolve ID | persist ID [allow-empty] | show ID | list
|
|
301
302
|
${c} obsidian proposals list | show OPERATION
|
|
302
303
|
|
|
303
|
-
Every input is an argument. view add changes the project configuration, which you commit: it shows the change and how many notes the view would show, and asks a person at a terminal first (--yes answers beforehand); anyone else's command is their consent, and nothing is ever committed. With --json exactly one JSON document is printed, for a refusal too. status, settings, location show, scope, audience show, mode show, policy show, policy digest, plugin show and service status are read-only. open reports current only when the owned maintenance service ticked after the request, the view's trusted generation is the prepared one and reads back byte for byte, and the installed Obsidian meets the minimum version and answers for that vault; every other outcome is typed and exits 3. Reaching the installed app is never a default: open, service start and service unit need --adapter=obsidian-cli once for a workspace, which remembers it (never used under the test runner). service unit --install installs a login item on macOS (a launchd agent) or Linux (a systemd user unit) so maintenance starts when you log in, under a consent that covers startup; it runs the package installed in the project and is refused from a package runner's cache. Once installed, the service is started through it, and service start replaces a service of an earlier release. service unit --remove removes it and starts the service for this session only. uninstall removes the login item and stops the service, and keeps vaults, private state, the project file and Obsidian's vault list. Obsidian's own settings file is written only while no Obsidian runs, and every write is shown in open's answer: the view's vault is added to its list with a backup of the file as it was, its command line is turned on (cli: true), and for an Obsidian that never started the file is created. A running Obsidian that open cannot reach (its command line turned off, or no vault open) is quit and started again only with --restart-obsidian, which no other flag implies; otherwise open names that flag. --wait-ms bounds open's wait for maintenance's tick. The first start of the maintenance service records who allowed it: --consent-actor ID, or for a person at a terminal the account's name; --json, --no-input, CI or ATELIER_NONINTERACTIVE=1 mean nobody is at a terminal. Audiences (audience set me: only you, with the notes that carry no classification, which no other list shows), mode, the apply policy and what was decided are private machine settings, never project configuration and never a note. apply, conflicts, apply-policy, selection and proposals are contributed operations registered from modules shipped under src/runtime/obsidian/contributions/; the built-in names cannot be replaced. While no apply operation is registered every apply reports apply-unavailable. Exit codes: 0 done, 1 internal error, 2 refusal or usage, 3 ran and the answer is not success.`,
|
|
304
|
+
Every input is an argument. view add changes the project configuration, which you commit: it shows the change and how many notes the view would show, and asks a person at a terminal first (--yes answers beforehand); anyone else's command is their consent, and nothing is ever committed. With --json exactly one JSON document is printed, for a refusal too. status, settings, location show, scope, audience show, mode show, policy show, policy digest, plugin show and service status are read-only. open reports current only when the owned maintenance service ticked after the request, the view's trusted generation is the prepared one and reads back byte for byte, and the installed Obsidian meets the minimum version and answers for that vault; every other outcome is typed and exits 3. Reaching the installed app is never a default: open, service start and service unit need --adapter=obsidian-cli once for a workspace, which remembers it (never used under the test runner). service unit --install installs a login item on macOS (a launchd agent) or Linux (a systemd user unit) so maintenance starts when you log in, under a consent that covers startup; it runs the package installed in the project and is refused from a package runner's cache. Once installed, the service is started through it, and service start replaces a service of an earlier release. service unit --remove removes it and starts the service for this session only. uninstall removes the login item and stops the service, and keeps vaults, private state, the project file and Obsidian's vault list. Obsidian's own settings file is written only while no Obsidian runs, and every write is shown in open's answer: the view's vault is added to its list with a backup of the file as it was, its command line is turned on (cli: true), and for an Obsidian that never started the file is created. A running Obsidian that open cannot reach (its command line turned off, or no vault open) is quit and started again only with --restart-obsidian, which no other flag implies; otherwise open names that flag. --wait-ms bounds open's wait for maintenance's tick. The first start of the maintenance service records who allowed it: --consent-actor ID, or for a person at a terminal the account's name; --json, --no-input, CI or ATELIER_NONINTERACTIVE=1 mean nobody is at a terminal. Audiences (audience set me: only you, with the notes that carry no classification, which no other list shows), mode, the apply policy and what was decided are private machine settings, never project configuration and never a note. apply, conflicts, apply-policy, selection and proposals are contributed operations registered from modules shipped under src/runtime/obsidian/contributions/; the built-in names cannot be replaced. While no apply operation is registered every apply reports apply-unavailable. --personal-home DIR, given instead of --project, follows the generation last confirmed in that private home of a personal workspace; nothing here confirms one, and while none is confirmed and current nothing is published and status, open and service start refuse with the reason. Who may see its vaults stays this machine's audience setting, none by default. Exit codes: 0 done, 1 internal error, 2 refusal or usage, 3 ran and the answer is not success.`,
|
|
304
305
|
sync: `Usage:
|
|
305
306
|
${c} sync enroll --repo DIR [--project atelier.project.json] [--git ABSOLUTE_PATH]
|
|
306
307
|
${c} sync status --repo DIR
|
|
@@ -69,7 +69,7 @@ const PRODUCTION_ADAPTER = 'obsidian-cli'
|
|
|
69
69
|
const MAX_POLICY_BYTES = 64 * 1024
|
|
70
70
|
const AUDIENCE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
71
71
|
|
|
72
|
-
export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.project.json] [--data-root DIR] [--json] [--no-input]
|
|
72
|
+
export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.project.json | --personal-home DIR] [--data-root DIR] [--json] [--no-input]
|
|
73
73
|
|
|
74
74
|
status Enablement, machine settings, service and per-view freshness. Read-only.
|
|
75
75
|
settings What this machine remembers for this workspace, and how to change it. Read-only.
|
|
@@ -111,6 +111,11 @@ export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.pro
|
|
|
111
111
|
service publishes the view at once (with --adapter, one of an earlier release
|
|
112
112
|
is replaced first).
|
|
113
113
|
|
|
114
|
+
--personal-home DIR, given instead of --project, follows the generation last confirmed in that private home of a personal
|
|
115
|
+
workspace (the personal-workspace module's selection); its views come from the overlay. Nothing here confirms one: while
|
|
116
|
+
none is confirmed and current nothing is published, and status, open and service start refuse with the reason. service
|
|
117
|
+
status and stop, unit --remove and uninstall find the service from the home alone. Who may see its vaults stays this
|
|
118
|
+
machine's audience setting, none by default.
|
|
114
119
|
Reaching the installed app needs --adapter=${PRODUCTION_ADAPTER} once for a workspace; it is remembered after that.
|
|
115
120
|
Obsidian's own settings file (obsidian.json) is written only while no Obsidian runs: open adds the view's vault to its
|
|
116
121
|
list, turns its command line on (cli: true), and creates the file for an Obsidian that never started; every write is
|
|
@@ -139,7 +144,7 @@ Exit codes: 0 done; 1 internal error; 2 refusal or usage; 3 ran, and the answer
|
|
|
139
144
|
// spelling of `project`); a test holds it to that.
|
|
140
145
|
export const FLAGS = Object.freeze({
|
|
141
146
|
json: 'flag', 'allow-stale': 'flag', 'restart-obsidian': 'flag', print: 'flag', install: 'flag', remove: 'flag', help: 'flag', 'no-input': 'flag', 'allow-synced-location': 'flag', all: 'flag', default: 'flag', 'allow-empty': 'flag', yes: 'flag',
|
|
142
|
-
project: 'value', 'project-config': 'value', 'data-root': 'value', scope: 'value', 'consent-actor': 'value', actor: 'value', adapter: 'value', 'wait-ms': 'value', repo: 'value', tag: 'value',
|
|
147
|
+
project: 'value', 'project-config': 'value', 'personal-home': 'value', 'data-root': 'value', scope: 'value', 'consent-actor': 'value', actor: 'value', adapter: 'value', 'wait-ms': 'value', repo: 'value', tag: 'value',
|
|
143
148
|
expand: 'value', folder: 'values',
|
|
144
149
|
})
|
|
145
150
|
|
|
@@ -276,6 +281,7 @@ const NEXT = Object.freeze({
|
|
|
276
281
|
'login-item-remove-failed': 'see `atelier obsidian status`; `atelier obsidian service unit --remove` tries again',
|
|
277
282
|
'login-item-file-unsafe': 'the login item\'s file is a link or not a regular file and is left alone; remove it by hand, then try again',
|
|
278
283
|
'invalid-login-item': 'the login item record in private state does not validate; inspect it, remove it, then install the login item again',
|
|
284
|
+
'selection-history-shortened': 'records of the selection history were removed after this process read them; put them back, or run the command in a process of its own, which reads the history as it now stands',
|
|
279
285
|
})
|
|
280
286
|
|
|
281
287
|
// The login item of `status`, in words.
|
|
@@ -372,10 +378,16 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
372
378
|
|
|
373
379
|
const dataRoot = flags['data-root'] === undefined ? options.dataRoot : path.resolve(cwd, flags['data-root'])
|
|
374
380
|
const configFlag = flags.project ?? flags['project-config']
|
|
375
|
-
|
|
381
|
+
// A personal home is followed instead of a project: the generation confirmed there is loaded (composed in this
|
|
382
|
+
// thread, as for a command run once), and the service's workspace is located from the home alone.
|
|
383
|
+
const personalHome = flags['personal-home'] === undefined ? null : path.resolve(cwd, flags['personal-home'])
|
|
384
|
+
if (personalHome !== null && configFlag !== undefined) refuse('usage', '--personal-home is given instead of --project, not with it')
|
|
385
|
+
const selection = personalHome === null ? null : await import('../runtime/obsidian/personal-selection.mjs')
|
|
386
|
+
const loadProject = options.loadProject ?? (selection !== null ? () => selection.loadSelectedProject({ personalHome }) : () => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
|
|
387
|
+
const locateProject = selection === null ? null : () => selection.locatePersonalHome({ personalHome })
|
|
376
388
|
const registry = createObsidianRegistry({ reservedOperations: BUILT_IN_OPERATIONS, contributions: contributions ?? await loadContributions(contributionsDirectory === undefined ? {} : { directory: contributionsDirectory }) })
|
|
377
389
|
const applyAvailable = registry.extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
|
|
378
|
-
const lifecycle = { loadProject, dataRoot, env, platform, ...(options.probeTimeoutMs === undefined ? {} : { probeTimeoutMs: options.probeTimeoutMs }) }
|
|
390
|
+
const lifecycle = { loadProject, ...(locateProject === null ? {} : { locateProject }), dataRoot, env, platform, ...(options.probeTimeoutMs === undefined ? {} : { probeTimeoutMs: options.probeTimeoutMs }) }
|
|
379
391
|
|
|
380
392
|
// The adapter of this run: given now, or remembered by this workspace (read only when the real entry has no flag).
|
|
381
393
|
const chooseAdapter = () => selectAdapter({ flag: flags.adapter, remembered: flags.adapter === undefined && production === true ? rememberedAdapter() : null, production, env })
|
|
@@ -617,7 +629,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
617
629
|
const removeUnit = async () => {
|
|
618
630
|
if (!STARTUP_PLATFORMS.includes(platform)) buildStartupAdapter({ platform })
|
|
619
631
|
const manager = await managerSeam()
|
|
620
|
-
const removed = await removeLoginItem({ loadProject, dataRoot, env, platform, manager, clock })
|
|
632
|
+
const removed = await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager, clock })
|
|
621
633
|
const failed = removed.removed !== true && removed.reason !== undefined && removed.reason !== 'workspace-not-prepared'
|
|
622
634
|
if (failed) return { exit: EXIT.notSuccess, document: { loginItem: removed }, human: [`login item not removed: ${removed.reason}${removed.message ? ` (${removed.message})` : ''}`, `Next: ${NEXT[removed.reason] ?? 'see `atelier obsidian status`'}`] }
|
|
623
635
|
const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off')
|
|
@@ -720,7 +732,10 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
720
732
|
const plugin = withPluginFilesPending(pluginView(running, workspace, scopeId), report.freshness)
|
|
721
733
|
return enablement.state === 'disabled' ? { ...report, outcome: 'disabled', reason: enablement.reason, next: OPENING_OUTCOMES.disabled.next, plugin } : { ...report, plugin }
|
|
722
734
|
})
|
|
735
|
+
// The confirmed selection a personal home's project was loaded under; reported, never confirmed here.
|
|
736
|
+
const pinned = selection === null ? null : selection.personalSelectionOf(project)
|
|
723
737
|
const document = {
|
|
738
|
+
...(pinned === null ? {} : { selection: { generationId: pinned.generationId, sequence: pinned.sequence } }),
|
|
724
739
|
enablement: { state: enablement.state, reason: enablement.reason, defaultScopeId: enablement.defaultScopeId }, workspace: { workspaceId, prepared: workspace !== null },
|
|
725
740
|
machine: shownMachine(machineOf(workspace), workspace), apply: applyShown,
|
|
726
741
|
service: { state: service.state, reason: service.reason ?? null, address: service.address ?? null, runtimeId: service.record?.runtimeId ?? null, pid: service.record?.pid ?? null, lastTick: running?.lastTick ?? null, lastError: running?.lastError ?? null, app },
|
|
@@ -729,6 +744,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
729
744
|
return {
|
|
730
745
|
exit: EXIT.ok, document,
|
|
731
746
|
human: [
|
|
747
|
+
...(pinned === null ? [] : [`personal workspace: generation ${pinned.generationId}, confirmed as selection ${pinned.sequence}`]),
|
|
732
748
|
`obsidian: ${enablement.state} (${enablement.reason}); mode ${document.machine.maintenanceMode}; audiences ${document.machine.audienceAllow.join(', ') || 'none'}`,
|
|
733
749
|
`remembered: ${decisionSummary(document.machine.decisions)}`,
|
|
734
750
|
`service: ${service.state} (${service.reason ?? 'no reason'})${app ? `; app ${app.outcome} (${app.reason})` : ''}`,
|
|
@@ -1073,13 +1089,13 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
|
|
|
1073
1089
|
// Maintenance of this workspace ends here: the login item goes first, so its manager does not start the service
|
|
1074
1090
|
// again, then the proven service stops. Everything a person made or may want back stays, and is named.
|
|
1075
1091
|
async uninstall() {
|
|
1076
|
-
const project = loadProject()
|
|
1092
|
+
const project = (locateProject ?? loadProject)()
|
|
1077
1093
|
let workspace = null
|
|
1078
1094
|
try { const found = resolveServiceWorkspace({ project, dataRoot, env, platform }); workspace = found?.workspaceRoot ? found : null } catch (error) { if (!isTyped(error)) throw error }
|
|
1079
1095
|
const record = workspace === null ? null : readLoginItemRecord(workspace)
|
|
1080
1096
|
// What is kept is read before anything is stopped or removed, so nothing it finds can stop the answer after that.
|
|
1081
1097
|
const kept = keptLocations(project, workspace)
|
|
1082
|
-
const item = record === null ? { removed: false, reason: 'not-installed' } : await removeLoginItem({ loadProject, dataRoot, env, platform, manager: await managerSeam(), clock })
|
|
1098
|
+
const item = record === null ? { removed: false, reason: 'not-installed' } : await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager: await managerSeam(), clock })
|
|
1083
1099
|
const service = workspace === null ? { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' } : await stopService(lifecycle, lifecycleRules)
|
|
1084
1100
|
const itemGone = item.removed === true || item.reason === 'not-installed' || item.reason === 'workspace-not-prepared'
|
|
1085
1101
|
const serviceGone = service.stopped === true || service.state === 'stopped'
|
package/src/graph/graph.mjs
CHANGED
|
@@ -5,9 +5,10 @@ import { VALID_AUDIENCES as PROJECTION_AUDIENCES } from '../projection/policy.mj
|
|
|
5
5
|
import {
|
|
6
6
|
REPO_ACCESS_SCHEMA,
|
|
7
7
|
VALID_RELATION_TYPES,
|
|
8
|
-
|
|
8
|
+
buildKnowledgeGraphSteps,
|
|
9
9
|
createGraphFileCache,
|
|
10
10
|
parseYamlSubset,
|
|
11
|
+
runGraphSteps,
|
|
11
12
|
} from './knowledge-graph.mjs'
|
|
12
13
|
|
|
13
14
|
export { createGraphFileCache }
|
|
@@ -116,11 +117,12 @@ function externalRepos(project) {
|
|
|
116
117
|
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
function
|
|
120
|
+
function* canonicalBuildSteps(project, overrides = {}) {
|
|
120
121
|
const input = canonicalInput(project)
|
|
121
|
-
const result =
|
|
122
|
+
const result = yield* buildKnowledgeGraphSteps({ ...input.options, ...overrides })
|
|
122
123
|
return { input, result }
|
|
123
124
|
}
|
|
125
|
+
const canonicalBuild = (project, overrides = {}) => runGraphSteps(canonicalBuildSteps(project, overrides))
|
|
124
126
|
|
|
125
127
|
// The supported project-to-options seam: the exact buildKnowledgeGraph options
|
|
126
128
|
// every graph consumer derives from a loaded project, plus the configured
|
|
@@ -144,8 +146,14 @@ const canonicalEdgeId = (edge) => JSON.stringify([edge.source, edge.type, edge.t
|
|
|
144
146
|
// bytes did not change; the result is the same as without it. `observedDigest`
|
|
145
147
|
// (see createGraphFileCache) lets a caller that observes sources by digest
|
|
146
148
|
// spare the read of a source it knows unchanged.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
// buildCanonicalGraphSteps is the same build as steps (see buildKnowledgeGraphSteps), for a caller that waits between
|
|
150
|
+
// them; buildCanonicalGraph runs them through.
|
|
151
|
+
export function buildCanonicalGraph(project, options = {}) {
|
|
152
|
+
return runGraphSteps(buildCanonicalGraphSteps(project, options))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function* buildCanonicalGraphSteps(project, { isLinkTargetEligible, isAssetEligible, fileCache = null, observedDigest = null } = {}) {
|
|
156
|
+
const { input, result } = yield* canonicalBuildSteps(project, {
|
|
149
157
|
...(isLinkTargetEligible ? { isLinkTargetEligible } : {}),
|
|
150
158
|
...(isAssetEligible ? { isAssetEligible } : {}),
|
|
151
159
|
...(fileCache ? { fileCache } : {}),
|
|
@@ -1387,7 +1387,24 @@ function markdownCensus({ repoName, repoRoot, coverage, file, accessConfig, cach
|
|
|
1387
1387
|
return { node: structuredClone(entry.node), scan: structuredClone(entry.scan), reused: entry === cached, read }
|
|
1388
1388
|
}
|
|
1389
1389
|
|
|
1390
|
-
|
|
1390
|
+
// Runs the steps of a graph build through, with no wait, and answers what the build answers.
|
|
1391
|
+
export function runGraphSteps(steps) {
|
|
1392
|
+
for (;;) {
|
|
1393
|
+
const step = steps.next()
|
|
1394
|
+
if (step.done) return step.value
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// The knowledge graph of a workspace, in one stretch. Every caller but the maintenance engine's tick builds it this way.
|
|
1399
|
+
export function buildKnowledgeGraph(options) {
|
|
1400
|
+
return runGraphSteps(buildKnowledgeGraphSteps(options))
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// The same build as steps: one after each Markdown source of the census (its read, hash and parse, or its reuse from
|
|
1404
|
+
// the file cache), named 'graph-census'. A caller that gives the event loop a turn between steps (the maintenance
|
|
1405
|
+
// engine) keeps a large census from holding it; walking the directories, resolving links and assembling the graph
|
|
1406
|
+
// are not split. The answer is the one buildKnowledgeGraph gives.
|
|
1407
|
+
export function* buildKnowledgeGraphSteps({
|
|
1391
1408
|
workspaceRoot,
|
|
1392
1409
|
repoAccessConfig,
|
|
1393
1410
|
repoRoots = null,
|
|
@@ -1466,6 +1483,7 @@ export function buildKnowledgeGraph({
|
|
|
1466
1483
|
scanned.set(node, entry.scan)
|
|
1467
1484
|
reuse[entry.reused ? 'reused' : 'derived'] += 1
|
|
1468
1485
|
if (entry.read) reuse.read += 1
|
|
1486
|
+
yield 'graph-census'
|
|
1469
1487
|
} else {
|
|
1470
1488
|
node = nodeForFile(repoName, repoRoot, coverage, file, accessConfig)
|
|
1471
1489
|
}
|
|
@@ -717,6 +717,11 @@ class GraphifySupplier:
|
|
|
717
717
|
settings = {"parallel": False, "max_workers": 1, **settings}
|
|
718
718
|
require(type(settings["parallel"]) is bool and type(settings["max_workers"]) is int
|
|
719
719
|
and 1 <= settings["max_workers"] <= 32, "BUDGET_UNBOUNDED", "Finite native structural worker bound required")
|
|
720
|
+
# Parallel extraction starts worker processes that import Graphify through the
|
|
721
|
+
# ordinary import system, outside the pinned source loader, so they may run a
|
|
722
|
+
# cached pyc this supplier never attested. Only in-process extraction is admitted.
|
|
723
|
+
require(settings["parallel"] is False, "NATIVE_WORKERS_UNATTESTED",
|
|
724
|
+
"Parallel native extraction runs worker processes outside the attested loader")
|
|
720
725
|
if op == "cache-check":
|
|
721
726
|
require(settings.get("prompt") is None or isinstance(settings["prompt"], str), "PROMPT_SCOPE", "Prompt must be explicit text")
|
|
722
727
|
if settings.get("prompt_file") is not None:
|