mandrel 2.20.0 → 2.22.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/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +15 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +113 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +6 -8
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
- package/.agents/workflows/helpers/deliver-story.md +8 -5
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -324,16 +324,20 @@ export function _resetWebSurfaceCache() {
|
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
/**
|
|
327
|
-
* True when the root `package.json` declares a
|
|
327
|
+
* True when the root `package.json` declares a dependency whose name (or a
|
|
328
|
+
* scope/name segment of it) appears in `packageList`. The shared probe behind
|
|
329
|
+
* {@link declaresWebFramework} and {@link declaresOrmDependency}.
|
|
330
|
+
*
|
|
328
331
|
* Returns `null` — *indeterminate* — when the manifest exists but cannot be
|
|
329
|
-
* read or parsed;
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
+
* read or parsed; callers fail open on that. A genuinely absent manifest
|
|
333
|
+
* (`ENOENT`) is determinate: there is no declaration, so there is no signal,
|
|
334
|
+
* and the file scan gets its turn.
|
|
332
335
|
*
|
|
333
336
|
* @param {string} root
|
|
337
|
+
* @param {readonly string[]} packageList
|
|
334
338
|
* @returns {boolean|null}
|
|
335
339
|
*/
|
|
336
|
-
function
|
|
340
|
+
function declaresDependencyIn(root, packageList) {
|
|
337
341
|
let raw;
|
|
338
342
|
try {
|
|
339
343
|
raw = readFileSync(path.join(root, 'package.json'), 'utf8');
|
|
@@ -355,10 +359,22 @@ function declaresWebFramework(root) {
|
|
|
355
359
|
name
|
|
356
360
|
.replace(/^@/, '')
|
|
357
361
|
.split('/')
|
|
358
|
-
.some((segment) =>
|
|
362
|
+
.some((segment) => packageList.includes(segment)),
|
|
359
363
|
);
|
|
360
364
|
}
|
|
361
365
|
|
|
366
|
+
/**
|
|
367
|
+
* True when the root `package.json` declares a web-framework dependency.
|
|
368
|
+
* Tri-state contract per {@link declaresDependencyIn}; the fail-open caller
|
|
369
|
+
* is {@link hasWebSurface}.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} root
|
|
372
|
+
* @returns {boolean|null}
|
|
373
|
+
*/
|
|
374
|
+
function declaresWebFramework(root) {
|
|
375
|
+
return declaresDependencyIn(root, WEB_FRAMEWORK_PACKAGES);
|
|
376
|
+
}
|
|
377
|
+
|
|
362
378
|
/**
|
|
363
379
|
* True when a bounded scan of the source tree finds a web asset file
|
|
364
380
|
* (`.html` / `.css` / `.jsx` / `.tsx`) outside the skipped directories.
|
|
@@ -514,38 +530,14 @@ export function _resetPersistenceLayerCache() {
|
|
|
514
530
|
|
|
515
531
|
/**
|
|
516
532
|
* True when the root `package.json` declares an ORM / query-builder dependency.
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
* (`ENOENT`) is determinate: there is no declaration, so the file scan gets its
|
|
520
|
-
* turn. Mirrors {@link declaresWebFramework}.
|
|
533
|
+
* Tri-state contract per {@link declaresDependencyIn}; the fail-open caller
|
|
534
|
+
* is {@link hasPersistenceLayer}.
|
|
521
535
|
*
|
|
522
536
|
* @param {string} root
|
|
523
537
|
* @returns {boolean|null}
|
|
524
538
|
*/
|
|
525
539
|
function declaresOrmDependency(root) {
|
|
526
|
-
|
|
527
|
-
try {
|
|
528
|
-
raw = readFileSync(path.join(root, 'package.json'), 'utf8');
|
|
529
|
-
} catch (err) {
|
|
530
|
-
if (err?.code === 'ENOENT') return false;
|
|
531
|
-
return null;
|
|
532
|
-
}
|
|
533
|
-
let pkg;
|
|
534
|
-
try {
|
|
535
|
-
pkg = JSON.parse(raw);
|
|
536
|
-
} catch {
|
|
537
|
-
return null;
|
|
538
|
-
}
|
|
539
|
-
const names = [
|
|
540
|
-
...Object.keys(pkg?.dependencies ?? {}),
|
|
541
|
-
...Object.keys(pkg?.devDependencies ?? {}),
|
|
542
|
-
];
|
|
543
|
-
return names.some((name) =>
|
|
544
|
-
name
|
|
545
|
-
.replace(/^@/, '')
|
|
546
|
-
.split('/')
|
|
547
|
-
.some((segment) => ORM_PACKAGES.includes(segment)),
|
|
548
|
-
);
|
|
540
|
+
return declaresDependencyIn(root, ORM_PACKAGES);
|
|
549
541
|
}
|
|
550
542
|
|
|
551
543
|
/**
|
|
@@ -802,24 +794,10 @@ export async function selectAudits({
|
|
|
802
794
|
}) {
|
|
803
795
|
const config = resolveConfig();
|
|
804
796
|
const timeoutMs = gitTimeoutMsOverride ?? DEFAULT_GIT_TIMEOUT_MS;
|
|
805
|
-
|
|
806
|
-
const rulesPath = path.join(
|
|
807
|
-
PROJECT_ROOT,
|
|
808
|
-
getPaths(config).schemasRoot,
|
|
809
|
-
'audit-rules.json',
|
|
810
|
-
);
|
|
811
|
-
let rulesData;
|
|
812
|
-
try {
|
|
813
|
-
rulesData = JSON.parse(await fs.readFile(rulesPath, 'utf8'));
|
|
814
|
-
} catch (err) {
|
|
815
|
-
throw new Error(
|
|
816
|
-
`Failed to read audit-rules from ${rulesPath}: ${err.message}`,
|
|
817
|
-
);
|
|
818
|
-
}
|
|
797
|
+
const rulesData = await loadAuditRules(config);
|
|
819
798
|
|
|
820
799
|
const ticket = await provider.getTicket(ticketId);
|
|
821
|
-
const contentToSearch =
|
|
822
|
-
`${ticket.title || ''} ${ticket.body || ''}`.toLowerCase();
|
|
800
|
+
const contentToSearch = ticketSearchText(ticket);
|
|
823
801
|
|
|
824
802
|
const runGit = injectedGitSpawn ?? (async (...args) => gitSpawn(...args));
|
|
825
803
|
|
|
@@ -831,102 +809,279 @@ export async function selectAudits({
|
|
|
831
809
|
// empty by construction.
|
|
832
810
|
const hasInjectedChangedFiles = Array.isArray(injectedChangedFiles);
|
|
833
811
|
|
|
834
|
-
// Resolve `headRef` to a commit before diffing. A non-default `headRef`
|
|
835
|
-
// (Epic-mode callers pass `refs/heads/epic/<id>`) that the repo can't
|
|
836
|
-
// resolve means the requested Epic's branch is not present in this
|
|
837
|
-
// checkout — diffing `baseBranch...HEAD` would silently report a
|
|
838
|
-
// *different* Epic's change set (Story #3362). Surface that as an explicit
|
|
839
|
-
// degraded signal instead of leaking the wrong scope. `HEAD` is always
|
|
840
|
-
// resolvable in a valid repo, so the default-path callers skip the probe
|
|
841
|
-
// cost on the common case.
|
|
842
812
|
if (!hasInjectedChangedFiles && headRef !== 'HEAD') {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
813
|
+
const degraded = await resolveHeadRefOrDegrade({
|
|
814
|
+
runGit,
|
|
815
|
+
headRef,
|
|
816
|
+
timeoutMs,
|
|
817
|
+
gateModeOpts,
|
|
818
|
+
});
|
|
819
|
+
if (degraded) return degraded;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
let changedFiles;
|
|
823
|
+
if (hasInjectedChangedFiles) {
|
|
824
|
+
changedFiles = injectedChangedFiles
|
|
825
|
+
.map((f) => String(f).trim())
|
|
826
|
+
.filter(Boolean);
|
|
827
|
+
} else {
|
|
828
|
+
const acquired = await acquireChangedFilesFromDiff({
|
|
829
|
+
runGit,
|
|
830
|
+
baseBranch,
|
|
831
|
+
headRef,
|
|
832
|
+
timeoutMs,
|
|
833
|
+
gateModeOpts,
|
|
834
|
+
});
|
|
835
|
+
if (acquired.degraded) return acquired.degraded;
|
|
836
|
+
changedFiles = acquired.files;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const selectedAudits = matchAuditRules({
|
|
840
|
+
audits: rulesData.audits ?? {},
|
|
841
|
+
gate,
|
|
842
|
+
contentToSearch,
|
|
843
|
+
changedFiles,
|
|
844
|
+
projectSupportsTarget: makeTargetApplicabilityProbe({
|
|
845
|
+
config,
|
|
846
|
+
hasWebSurfaceFn,
|
|
847
|
+
hasPersistenceLayerFn,
|
|
848
|
+
}),
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
return {
|
|
852
|
+
selectedAudits,
|
|
853
|
+
ticketId,
|
|
854
|
+
gate,
|
|
855
|
+
context: {
|
|
856
|
+
// Full file list, exposed so Epic-mode callers (e.g. epic-audit) can
|
|
857
|
+
// pass it through as the {{changedFiles}} substitution value. Existing
|
|
858
|
+
// callers that read only `changedFilesCount` remain unaffected.
|
|
859
|
+
changedFiles,
|
|
860
|
+
changedFilesCount: changedFiles.length,
|
|
861
|
+
// The ref the change set was actually diffed against. Epic-mode callers
|
|
862
|
+
// assert this matches the requested Epic branch (Story #3362) so a
|
|
863
|
+
// mis-pinned diff never reaches the audit-lens selector silently. `null`
|
|
864
|
+
// on the injected path: no ref was diffed, and naming one would invite
|
|
865
|
+
// exactly that assertion to pass against a diff nobody took.
|
|
866
|
+
resolvedRef: hasInjectedChangedFiles ? null : headRef,
|
|
867
|
+
ticketTitle: ticket.title,
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Read and parse `audit-rules.json` from the configured schemas root.
|
|
874
|
+
*
|
|
875
|
+
* @param {object} config Resolved `.agentrc.json` wrapper.
|
|
876
|
+
* @returns {Promise<object>}
|
|
877
|
+
*/
|
|
878
|
+
async function loadAuditRules(config) {
|
|
879
|
+
const rulesPath = path.join(
|
|
880
|
+
PROJECT_ROOT,
|
|
881
|
+
getPaths(config).schemasRoot,
|
|
882
|
+
'audit-rules.json',
|
|
883
|
+
);
|
|
884
|
+
try {
|
|
885
|
+
return JSON.parse(await fs.readFile(rulesPath, 'utf8'));
|
|
886
|
+
} catch (err) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
`Failed to read audit-rules from ${rulesPath}: ${err.message}`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Lower-cased ticket text the keyword triggers match against. */
|
|
894
|
+
function ticketSearchText(ticket) {
|
|
895
|
+
return `${ticket.title || ''} ${ticket.body || ''}`.toLowerCase();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Resolve a non-default `headRef` to a commit before diffing. A ref the repo
|
|
900
|
+
* cannot resolve means the requested branch is not present in this checkout —
|
|
901
|
+
* diffing `baseBranch...HEAD` would silently report a *different* change set
|
|
902
|
+
* (Story #3362) — so that surfaces as an explicit degraded signal instead of
|
|
903
|
+
* leaking the wrong scope. Returns `null` when the ref resolves; otherwise
|
|
904
|
+
* the degraded envelope from `softFailOrThrow` (which throws in gate-mode).
|
|
905
|
+
* A non-timeout spawn failure propagates.
|
|
906
|
+
*
|
|
907
|
+
* @param {{ runGit: Function, headRef: string, timeoutMs: number, gateModeOpts?: object }} params
|
|
908
|
+
* @returns {Promise<object|null>}
|
|
909
|
+
*/
|
|
910
|
+
async function resolveHeadRefOrDegrade({
|
|
911
|
+
runGit,
|
|
912
|
+
headRef,
|
|
913
|
+
timeoutMs,
|
|
914
|
+
gateModeOpts,
|
|
915
|
+
}) {
|
|
916
|
+
let resolved;
|
|
917
|
+
try {
|
|
918
|
+
resolved = await withTimeout(
|
|
919
|
+
runGit(process.cwd(), 'rev-parse', '--verify', '--quiet', headRef),
|
|
920
|
+
timeoutMs,
|
|
921
|
+
{ label: 'select-audits rev-parse headRef' },
|
|
922
|
+
);
|
|
923
|
+
} catch (err) {
|
|
924
|
+
if (err?.code === 'ETIMEDOUT') {
|
|
861
925
|
return softFailOrThrow(
|
|
862
|
-
'
|
|
863
|
-
`select-audits:
|
|
926
|
+
'GIT_DIFF_TIMEOUT',
|
|
927
|
+
`select-audits: git rev-parse ${headRef} timed out after ${timeoutMs} ms`,
|
|
864
928
|
gateModeOpts,
|
|
865
929
|
);
|
|
866
930
|
}
|
|
931
|
+
throw err;
|
|
867
932
|
}
|
|
933
|
+
if (resolved?.status !== 0 || !resolved.stdout.trim()) {
|
|
934
|
+
return softFailOrThrow(
|
|
935
|
+
'HEAD_REF_UNRESOLVED',
|
|
936
|
+
`select-audits: requested ref '${headRef}' could not be resolved in this checkout; refusing to diff against a phantom change set`,
|
|
937
|
+
gateModeOpts,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
868
942
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
943
|
+
/**
|
|
944
|
+
* Acquire the change set from `git diff --name-only base...head`. Returns
|
|
945
|
+
* `{ files }` on success — a diff that exits non-zero yields an empty list,
|
|
946
|
+
* preserving the pre-extraction flow — or `{ degraded }` with the soft-fail
|
|
947
|
+
* envelope on timeout. A non-timeout spawn failure propagates.
|
|
948
|
+
*
|
|
949
|
+
* @param {{ runGit: Function, baseBranch: string, headRef: string, timeoutMs: number, gateModeOpts?: object }} params
|
|
950
|
+
* @returns {Promise<{ files?: string[], degraded?: object }>}
|
|
951
|
+
*/
|
|
952
|
+
async function acquireChangedFilesFromDiff({
|
|
953
|
+
runGit,
|
|
954
|
+
baseBranch,
|
|
955
|
+
headRef,
|
|
956
|
+
timeoutMs,
|
|
957
|
+
gateModeOpts,
|
|
958
|
+
}) {
|
|
959
|
+
let diff;
|
|
872
960
|
try {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
{ label: 'select-audits git diff' },
|
|
884
|
-
);
|
|
885
|
-
if (diff?.status === 0) {
|
|
886
|
-
changedFiles = diff.stdout
|
|
887
|
-
.split('\n')
|
|
888
|
-
.map((f) => f.trim())
|
|
889
|
-
.filter(Boolean);
|
|
890
|
-
}
|
|
961
|
+
diff = await withTimeout(
|
|
962
|
+
runGit(
|
|
963
|
+
process.cwd(),
|
|
964
|
+
'diff',
|
|
965
|
+
'--name-only',
|
|
966
|
+
`${baseBranch}...${headRef}`,
|
|
967
|
+
),
|
|
968
|
+
timeoutMs,
|
|
969
|
+
{ label: 'select-audits git diff' },
|
|
970
|
+
);
|
|
891
971
|
} catch (err) {
|
|
892
972
|
if (err?.code === 'ETIMEDOUT') {
|
|
893
973
|
// Soft-fail contract (Tech Spec #819): in default mode, return a
|
|
894
974
|
// degraded envelope so the caller sees the explicit signal instead of
|
|
895
975
|
// silently falling through to keyword-only matching. In gate-mode,
|
|
896
976
|
// hard-fail closed.
|
|
897
|
-
return
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
977
|
+
return {
|
|
978
|
+
degraded: softFailOrThrow(
|
|
979
|
+
'GIT_DIFF_TIMEOUT',
|
|
980
|
+
`select-audits: git diff against ${baseBranch} timed out after ${timeoutMs} ms`,
|
|
981
|
+
gateModeOpts,
|
|
982
|
+
),
|
|
983
|
+
};
|
|
902
984
|
}
|
|
903
985
|
throw err;
|
|
904
986
|
}
|
|
987
|
+
if (diff?.status !== 0) return { files: [] };
|
|
988
|
+
return {
|
|
989
|
+
files: diff.stdout
|
|
990
|
+
.split('\n')
|
|
991
|
+
.map((f) => f.trim())
|
|
992
|
+
.filter(Boolean),
|
|
993
|
+
};
|
|
994
|
+
}
|
|
905
995
|
|
|
906
|
-
|
|
996
|
+
/**
|
|
997
|
+
* Whole-word keyword match over the ticket's search text: a bare substring
|
|
998
|
+
* test selects lenses on accidental fragments ("ui" inside "requires",
|
|
999
|
+
* "auth" inside "author" — #4579).
|
|
1000
|
+
*
|
|
1001
|
+
* @param {string[]} keywords
|
|
1002
|
+
* @param {string} content Lower-cased ticket text.
|
|
1003
|
+
* @returns {boolean}
|
|
1004
|
+
*/
|
|
1005
|
+
function matchesAnyKeyword(keywords, content) {
|
|
1006
|
+
return keywords.some((kw) => {
|
|
1007
|
+
const escaped = kw.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1008
|
+
return new RegExp(`\\b${escaped}\\b`).test(content);
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
907
1011
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1012
|
+
/**
|
|
1013
|
+
* Build the once-per-call target-applicability probe. Probes resolve at most
|
|
1014
|
+
* once per target, and only if a lens declaring that target actually clears
|
|
1015
|
+
* its gate — a Node-only project must not pay a filesystem scan on a roster
|
|
1016
|
+
* with no `web` lens in it, nor a DB-less project pay one for `data-model`.
|
|
1017
|
+
*
|
|
1018
|
+
* @param {{ config: object, hasWebSurfaceFn: Function, hasPersistenceLayerFn: Function }} params
|
|
1019
|
+
* @returns {(target: string) => boolean}
|
|
1020
|
+
*/
|
|
1021
|
+
function makeTargetApplicabilityProbe({
|
|
1022
|
+
config,
|
|
1023
|
+
hasWebSurfaceFn,
|
|
1024
|
+
hasPersistenceLayerFn,
|
|
1025
|
+
}) {
|
|
1026
|
+
const probes = {
|
|
913
1027
|
web: hasWebSurfaceFn,
|
|
914
1028
|
'data-model': hasPersistenceLayerFn,
|
|
915
1029
|
};
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
if (!
|
|
919
|
-
const probe =
|
|
920
|
-
|
|
1030
|
+
const memo = new Map();
|
|
1031
|
+
return (target) => {
|
|
1032
|
+
if (!memo.has(target)) {
|
|
1033
|
+
const probe = probes[target];
|
|
1034
|
+
memo.set(target, probe ? probe({ config }) : true);
|
|
921
1035
|
}
|
|
922
|
-
return
|
|
1036
|
+
return memo.get(target);
|
|
923
1037
|
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* True when any of a lens's content triggers fires against the change set:
|
|
1042
|
+
* whole-word keywords, file-pattern globs, or the coverage-gap
|
|
1043
|
+
* `sourceWithoutSiblingTest` predicate (#4628).
|
|
1044
|
+
*
|
|
1045
|
+
* @param {object} triggers The lens's `triggers` block.
|
|
1046
|
+
* @param {string} contentToSearch Lower-cased ticket text.
|
|
1047
|
+
* @param {string[]} changedFiles
|
|
1048
|
+
* @returns {boolean}
|
|
1049
|
+
*/
|
|
1050
|
+
function lensTriggerFires(triggers, contentToSearch, changedFiles) {
|
|
1051
|
+
return (
|
|
1052
|
+
matchesAnyKeyword(triggers.keywords || [], contentToSearch) ||
|
|
1053
|
+
matchesAnyFilePattern(triggers.filePatterns || [], changedFiles) ||
|
|
1054
|
+
(triggers.sourceWithoutSiblingTest === true &&
|
|
1055
|
+
changeSetLacksSiblingTest(changedFiles))
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
924
1058
|
|
|
925
|
-
|
|
1059
|
+
/**
|
|
1060
|
+
* Run the audit-rules matching loop over one gate's roster: a lens is
|
|
1061
|
+
* selected when its gate matches, its `target` applicability gate passes,
|
|
1062
|
+
* and any of its keyword / file-pattern / sibling-test triggers fires.
|
|
1063
|
+
*
|
|
1064
|
+
* @param {{
|
|
1065
|
+
* audits: Record<string, object>,
|
|
1066
|
+
* gate: string,
|
|
1067
|
+
* contentToSearch: string,
|
|
1068
|
+
* changedFiles: string[],
|
|
1069
|
+
* projectSupportsTarget: (target: string) => boolean,
|
|
1070
|
+
* }} params
|
|
1071
|
+
* @returns {string[]} Selected lens identifiers, in manifest order.
|
|
1072
|
+
*/
|
|
1073
|
+
function matchAuditRules({
|
|
1074
|
+
audits,
|
|
1075
|
+
gate,
|
|
1076
|
+
contentToSearch,
|
|
1077
|
+
changedFiles,
|
|
1078
|
+
projectSupportsTarget,
|
|
1079
|
+
}) {
|
|
1080
|
+
const selectedAudits = [];
|
|
1081
|
+
for (const [auditName, ruleOpts] of Object.entries(audits)) {
|
|
926
1082
|
const triggers = ruleOpts.triggers || {};
|
|
927
1083
|
|
|
928
|
-
|
|
929
|
-
if (!gateMatch) continue;
|
|
1084
|
+
if (!triggers.gates?.includes(gate)) continue;
|
|
930
1085
|
|
|
931
1086
|
// Target-applicability gate (#4579, #4633). A lens declaring a `target`
|
|
932
1087
|
// has nothing to read on a project lacking that surface, yet still
|
|
@@ -939,51 +1094,9 @@ export async function selectAudits({
|
|
|
939
1094
|
const { target } = ruleOpts;
|
|
940
1095
|
if (target && target !== 'any' && !projectSupportsTarget(target)) continue;
|
|
941
1096
|
|
|
942
|
-
|
|
943
|
-
let keywordMatch = false;
|
|
944
|
-
for (const kw of keywords) {
|
|
945
|
-
// Whole-word match: a bare substring test selects lenses on accidental
|
|
946
|
-
// fragments ("ui" inside "requires", "auth" inside "author" — #4579).
|
|
947
|
-
const escaped = kw.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
948
|
-
if (new RegExp(`\\b${escaped}\\b`).test(contentToSearch)) {
|
|
949
|
-
keywordMatch = true;
|
|
950
|
-
break;
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
const fileMatch = matchesAnyFilePattern(
|
|
955
|
-
triggers.filePatterns || [],
|
|
956
|
-
changedFiles,
|
|
957
|
-
);
|
|
958
|
-
|
|
959
|
-
// Coverage-gap routing (#4628): a lens declaring `sourceWithoutSiblingTest`
|
|
960
|
-
// fires when the change set touches source lacking a sibling test.
|
|
961
|
-
const siblingMatch =
|
|
962
|
-
triggers.sourceWithoutSiblingTest === true &&
|
|
963
|
-
changeSetLacksSiblingTest(changedFiles);
|
|
964
|
-
|
|
965
|
-
if (keywordMatch || fileMatch || siblingMatch) {
|
|
1097
|
+
if (lensTriggerFires(triggers, contentToSearch, changedFiles)) {
|
|
966
1098
|
selectedAudits.push(auditName);
|
|
967
1099
|
}
|
|
968
1100
|
}
|
|
969
|
-
|
|
970
|
-
return {
|
|
971
|
-
selectedAudits,
|
|
972
|
-
ticketId,
|
|
973
|
-
gate,
|
|
974
|
-
context: {
|
|
975
|
-
// Full file list, exposed so Epic-mode callers (e.g. epic-audit) can
|
|
976
|
-
// pass it through as the {{changedFiles}} substitution value. Existing
|
|
977
|
-
// callers that read only `changedFilesCount` remain unaffected.
|
|
978
|
-
changedFiles,
|
|
979
|
-
changedFilesCount: changedFiles.length,
|
|
980
|
-
// The ref the change set was actually diffed against. Epic-mode callers
|
|
981
|
-
// assert this matches the requested Epic branch (Story #3362) so a
|
|
982
|
-
// mis-pinned diff never reaches the audit-lens selector silently. `null`
|
|
983
|
-
// on the injected path: no ref was diffed, and naming one would invite
|
|
984
|
-
// exactly that assertion to pass against a diff nobody took.
|
|
985
|
-
resolvedRef: hasInjectedChangedFiles ? null : headRef,
|
|
986
|
-
ticketTitle: ticket.title,
|
|
987
|
-
},
|
|
988
|
-
};
|
|
1101
|
+
return selectedAudits;
|
|
989
1102
|
}
|
|
@@ -185,12 +185,22 @@ export function _clearTestContextScratchCache() {
|
|
|
185
185
|
* process was started with the `--test` flag (a direct `node --test <file>`
|
|
186
186
|
* runner process, or in-process isolation modes).
|
|
187
187
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
188
|
+
* Exported since Story #4837: the feedback loop's issue-filing path guards
|
|
189
|
+
* live GitHub writes on this same signal, and a second hand-rolled copy of
|
|
190
|
+
* the detection is exactly how the two would drift apart.
|
|
191
|
+
*
|
|
192
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
193
|
+
* @param {string[]} [execArgv=process.execArgv]
|
|
190
194
|
* @returns {boolean}
|
|
191
195
|
*/
|
|
192
|
-
function inNodeTestContext(
|
|
193
|
-
|
|
196
|
+
export function inNodeTestContext(
|
|
197
|
+
env = process.env,
|
|
198
|
+
execArgv = process.execArgv,
|
|
199
|
+
) {
|
|
200
|
+
return (
|
|
201
|
+
Boolean(env?.NODE_TEST_CONTEXT) ||
|
|
202
|
+
(Array.isArray(execArgv) && execArgv.includes('--test'))
|
|
203
|
+
);
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
/**
|
|
@@ -312,6 +322,68 @@ export function orchestrationLogDir(config) {
|
|
|
312
322
|
return path.join(anchorTempRoot(tempRootFrom(config)), ORCHESTRATION_DIRNAME);
|
|
313
323
|
}
|
|
314
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Basename of one Story's close gate log (Story #4816 lifted it here from
|
|
327
|
+
* `single-story-close/gate-log.js`).
|
|
328
|
+
*
|
|
329
|
+
* The writer that appends this file and the reader that uses its **freshness**
|
|
330
|
+
* to tell a live close from a dead one (`deliver-recover.js`) sit in different
|
|
331
|
+
* subtrees, and the reader importing the writer is the wrong edge to draw for
|
|
332
|
+
* a filename. Both take it from the module that already owns every other
|
|
333
|
+
* tempRoot path instead.
|
|
334
|
+
*
|
|
335
|
+
* `null` is the sink's no-Story sentinel and keeps its `unknown` spelling.
|
|
336
|
+
*
|
|
337
|
+
* @param {number|null} sid
|
|
338
|
+
* @returns {string}
|
|
339
|
+
*/
|
|
340
|
+
function closeGateLogName(sid) {
|
|
341
|
+
return `close-gates-${sid ?? 'unknown'}.log`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `<tempRoot>/orchestration/close-gates-<sid>.log`.
|
|
346
|
+
*
|
|
347
|
+
* @param {number|null} sid
|
|
348
|
+
* @param {object} [config]
|
|
349
|
+
* @returns {string}
|
|
350
|
+
*/
|
|
351
|
+
export function closeGateLogPath(sid, config) {
|
|
352
|
+
return path.join(orchestrationLogDir(config), closeGateLogName(sid));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Basename of the persisted terminal envelope for one Story (Story #4816).
|
|
357
|
+
*
|
|
358
|
+
* @param {number} sid
|
|
359
|
+
* @returns {string}
|
|
360
|
+
*/
|
|
361
|
+
function storyTerminalEnvelopeName(sid) {
|
|
362
|
+
return `story-deliver-terminal-${storyId(sid)}.json`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* `<tempRoot>/orchestration/story-deliver-terminal-<sid>.json` — the on-disk
|
|
367
|
+
* copy of the one terminal envelope a Story's close-and-land emits (Story
|
|
368
|
+
* #4816).
|
|
369
|
+
*
|
|
370
|
+
* Deliberately a sibling of the gate log rather than a per-Story temp dir
|
|
371
|
+
* entry: the envelope is a run artifact of the same close that writes
|
|
372
|
+
* `close-gates-<sid>.log`, and `deliver-recover.js` reads the pair together to
|
|
373
|
+
* tell a finished close from a live one. Sharing `orchestrationLogDir` also
|
|
374
|
+
* means it inherits main-checkout anchoring for free — the close runs inside
|
|
375
|
+
* `.worktrees/story-<sid>/` while the `/deliver` host reads from the main
|
|
376
|
+
* checkout, and an un-anchored path would put the envelope somewhere the
|
|
377
|
+
* router never looks.
|
|
378
|
+
*
|
|
379
|
+
* @param {number} sid
|
|
380
|
+
* @param {object} [config]
|
|
381
|
+
* @returns {string}
|
|
382
|
+
*/
|
|
383
|
+
export function storyTerminalEnvelopePath(sid, config) {
|
|
384
|
+
return path.join(orchestrationLogDir(config), storyTerminalEnvelopeName(sid));
|
|
385
|
+
}
|
|
386
|
+
|
|
315
387
|
const runId = (id) => {
|
|
316
388
|
if (!Number.isInteger(id) || id <= 0) {
|
|
317
389
|
throw new Error(`[temp-paths] runId must be a positive integer; got ${id}`);
|
|
@@ -395,11 +467,19 @@ export function storyTempDir(eid, sid, config) {
|
|
|
395
467
|
const checkedEid = storyEpicId(eid);
|
|
396
468
|
const parent =
|
|
397
469
|
checkedEid === null
|
|
398
|
-
? path.join(anchorTempRoot(tempRootFrom(config)),
|
|
470
|
+
? path.join(anchorTempRoot(tempRootFrom(config)), STANDALONE_DIRNAME)
|
|
399
471
|
: runTempDir(checkedEid, config);
|
|
400
|
-
return path.join(parent,
|
|
472
|
+
return path.join(parent, STORIES_DIRNAME, `story-${storyId(sid)}`);
|
|
401
473
|
}
|
|
402
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Basename of a per-Story signal stream. Exported so the reader that
|
|
477
|
+
* *discovers* streams by walking the temp tree (`signals-writer.js`'s
|
|
478
|
+
* cross-Story gather, Story #4824) names the same file the writer does,
|
|
479
|
+
* instead of re-spelling the literal in a second module.
|
|
480
|
+
*/
|
|
481
|
+
export const SIGNALS_BASENAME = 'signals.ndjson';
|
|
482
|
+
|
|
403
483
|
/**
|
|
404
484
|
* `temp/run-<eid>/stories/story-<sid>/signals.ndjson` — append-only
|
|
405
485
|
* signal stream consumed by the analyzer (Epic #1030 AC1).
|
|
@@ -410,7 +490,33 @@ export function storyTempDir(eid, sid, config) {
|
|
|
410
490
|
* @returns {string}
|
|
411
491
|
*/
|
|
412
492
|
export function signalsFile(eid, sid, config) {
|
|
413
|
-
return path.join(storyTempDir(eid, sid, config),
|
|
493
|
+
return path.join(storyTempDir(eid, sid, config), SIGNALS_BASENAME);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Directory segment holding the standalone-Story subtree —
|
|
498
|
+
* `<tempRoot>/standalone/stories/story-<sid>/`. Named here (rather than
|
|
499
|
+
* inlined in `storyTempDir`) so the cross-Story stream discovery in
|
|
500
|
+
* `signals-writer.js` can recognise it without re-spelling the literal.
|
|
501
|
+
*/
|
|
502
|
+
export const STANDALONE_DIRNAME = 'standalone';
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Directory segment holding the per-Story subtree under either an Epic run
|
|
506
|
+
* dir or the standalone dir (Story #2940's separator).
|
|
507
|
+
*/
|
|
508
|
+
export const STORIES_DIRNAME = 'stories';
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* `<tempRoot>` itself, resolved and main-checkout-anchored. The discovery
|
|
512
|
+
* walk needs the root the named helpers are built from; every other consumer
|
|
513
|
+
* should keep using a named helper.
|
|
514
|
+
*
|
|
515
|
+
* @param {object} [config]
|
|
516
|
+
* @returns {string}
|
|
517
|
+
*/
|
|
518
|
+
export function resolvedTempRoot(config) {
|
|
519
|
+
return anchorTempRoot(tempRootFrom(config));
|
|
414
520
|
}
|
|
415
521
|
|
|
416
522
|
/**
|